Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65ae73bcc | ||
|
|
85dc873e16 | ||
|
|
1c475ee856 | ||
|
|
69f759b6ce | ||
|
|
c54ee7876c | ||
|
|
9ba2c68ff9 | ||
|
|
db460fb9b3 | ||
|
|
b3ce276d1b | ||
|
|
682e76de2f | ||
|
|
70a0ff0e84 | ||
|
|
085d6dc296 | ||
|
|
59dba20c1e | ||
|
|
76574e2bee |
Binary file not shown.
@@ -7,7 +7,7 @@ A **native Linux** GUI for mass renaming files. Inspired by [Bulk Rename Utility
|
|||||||
- **GUI** – Structure renames with rule panels; no command line needed.
|
- **GUI** – Structure renames with rule panels; no command line needed.
|
||||||
- **Preview** – See “Original → New name” for every file before committing.
|
- **Preview** – See “Original → New name” for every file before committing.
|
||||||
- **Multiple rules** – Combine rules (order: Replace → Regex → Insert → Remove → Case → Numbering → Episode renumber → Prefix/Suffix). Enable only the rules you need.
|
- **Multiple rules** – Combine rules (order: Replace → Regex → Insert → Remove → Case → Numbering → Episode renumber → Prefix/Suffix). Enable only the rules you need.
|
||||||
- **Episode renumbering** – Replace episode numbers (e.g. `S01E05 - Title` → `S01E01 - Title`) while keeping season and title. Start number and zero-padding configurable.
|
- **Episode renumbering** – Replace episode numbers (e.g. `S01E05 - Title` → `S01E01 - Title`) while keeping season and title. Multi-episode files like `S01E05-E06` become `S01E01-E02` (range length preserved). Start, step, and zero-padding configurable.
|
||||||
- **Replace / Regex** – Plain text find/replace or full regex with capture groups.
|
- **Replace / Regex** – Plain text find/replace or full regex with capture groups.
|
||||||
- **Insert / Remove** – Insert text at start/end/position; remove text, digits, or first/last N characters.
|
- **Insert / Remove** – Insert text at start/end/position; remove text, digits, or first/last N characters.
|
||||||
- **Case** – Title Case, UPPER, lower, Sentence case.
|
- **Case** – Title Case, UPPER, lower, Sentence case.
|
||||||
@@ -62,6 +62,10 @@ Sort order is the current list order (alphabetical by filename). Reorder files i
|
|||||||
3. Enable **9. CSV mapping**, click **Browse…** and select the CSV.
|
3. Enable **9. CSV mapping**, click **Browse…** and select the CSV.
|
||||||
4. Only rows that match a current filename in the folder are renamed; others are unchanged. You can combine with other rules (CSV is applied in rule order).
|
4. Only rows that match a current filename in the folder are renamed; others are unchanged. You can combine with other rules (CSV is applied in rule order).
|
||||||
|
|
||||||
|
## Gear Lever (AppImage updates)
|
||||||
|
|
||||||
|
To have [Gear Lever](https://github.com/mijorus/gearlever) see new releases when we push them and update the AppImage from there, see **[docs/GEARLEVER.md](docs/GEARLEVER.md)** for configuration (custom update URL for Gitea, or static URL).
|
||||||
|
|
||||||
## AppImage (distribution)
|
## AppImage (distribution)
|
||||||
|
|
||||||
To build a portable AppImage:
|
To build a portable AppImage:
|
||||||
|
|||||||
+28
-3
@@ -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
|
||||||
@@ -62,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
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Using HSRename with Gear Lever
|
||||||
|
|
||||||
|
[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.
|
||||||
|
|
||||||
|
## Important: use Forgejo, not Default
|
||||||
|
|
||||||
|
In the HSRename details screen:
|
||||||
|
|
||||||
|
- **Source** must be **Forgejo** (not `Default`).
|
||||||
|
- The **Website** field is only metadata. It does **not** check for updates.
|
||||||
|
- Do **not** paste the release download URL into Website.
|
||||||
|
|
||||||
|
## Configure updates (Gear Lever 4+)
|
||||||
|
|
||||||
|
1. Open **Gear Lever** → select **HSRename**.
|
||||||
|
2. Set **Source** to **Forgejo**.
|
||||||
|
3. Fill in:
|
||||||
|
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
4. Save, then use **Check for updates** / **List updates**.
|
||||||
|
|
||||||
|
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 **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
|
||||||
|
|
||||||
|
For release tag `vX.Y.Z`:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/vX.Y.Z/HSRename.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
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
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
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] | 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.65,
|
||||||
|
season_filter: int = 0,
|
||||||
|
) -> 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.
|
||||||
|
Returns mapping filename -> (season, episode), unmatched list, notes.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
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"], parsed))
|
||||||
|
|
||||||
|
ep_entries = [
|
||||||
|
(ep.season_number, ep.number, normalize_title(ep.name), ep.name)
|
||||||
|
for ep in episodes
|
||||||
|
]
|
||||||
|
|
||||||
|
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, season, ep_num, raw_title, ep_name))
|
||||||
|
|
||||||
|
pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
||||||
|
|
||||||
|
mapping: dict[str, EpisodeTarget] = {}
|
||||||
|
used_files: set[str] = set()
|
||||||
|
used_eps: set[tuple[int, int]] = set()
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
for score, fname, season, ep_num, raw_title, ep_name in pairs:
|
||||||
|
if score < min_score:
|
||||||
|
break
|
||||||
|
ep_key = (season, ep_num)
|
||||||
|
if fname in used_files or ep_key in used_eps:
|
||||||
|
continue
|
||||||
|
mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
|
||||||
|
used_files.add(fname)
|
||||||
|
used_eps.add(ep_key)
|
||||||
|
pct = int(round(score * 100))
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Scan folders for rename candidates with optional recursion and extension filters."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .pipeline import UNDO_FILENAME
|
||||||
|
|
||||||
|
FILE_TYPE_PRESETS: dict[str, str] = {
|
||||||
|
"All files": "",
|
||||||
|
"Video": ".mkv,.mp4,.avi,.webm,.m4v,.mov",
|
||||||
|
"Audio": ".mp3,.flac,.wav,.ogg,.m4a,.aac",
|
||||||
|
"Images": ".jpg,.jpeg,.png,.gif,.webp,.bmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extension_filter(spec: str) -> frozenset[str] | None:
|
||||||
|
"""
|
||||||
|
Parse a user extension filter into a set of lowercase extensions with a leading dot.
|
||||||
|
Returns None when empty (match all files).
|
||||||
|
"""
|
||||||
|
spec = spec.strip()
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
exts: set[str] = set()
|
||||||
|
for part in re.split(r"[,;\s]+", spec):
|
||||||
|
part = part.strip().lower()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part.startswith("*."):
|
||||||
|
part = part[1:]
|
||||||
|
if not part.startswith("."):
|
||||||
|
part = "." + part
|
||||||
|
exts.add(part)
|
||||||
|
return frozenset(exts) if exts else None
|
||||||
|
|
||||||
|
|
||||||
|
def file_extension(rel_path: str) -> str:
|
||||||
|
"""Return the lowercase extension (with dot) of the basename, or '' if none."""
|
||||||
|
name = Path(rel_path).name
|
||||||
|
if "." in name and not name.startswith("."):
|
||||||
|
return "." + name.rsplit(".", 1)[-1].lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def matches_extension(rel_path: str, extensions: frozenset[str] | None) -> bool:
|
||||||
|
if extensions is None:
|
||||||
|
return True
|
||||||
|
return file_extension(rel_path) in extensions
|
||||||
|
|
||||||
|
|
||||||
|
def list_files(
|
||||||
|
base_dir: str,
|
||||||
|
*,
|
||||||
|
recursive: bool = False,
|
||||||
|
extensions: frozenset[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return sorted paths relative to base_dir."""
|
||||||
|
base = Path(base_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
found: list[str] = []
|
||||||
|
|
||||||
|
def consider(path: Path) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
if path.name.startswith("."):
|
||||||
|
return
|
||||||
|
if path.name == UNDO_FILENAME:
|
||||||
|
return
|
||||||
|
rel = path.relative_to(base).as_posix()
|
||||||
|
if matches_extension(rel, extensions):
|
||||||
|
found.append(rel)
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
for dirpath, dirnames, filenames in os.walk(base):
|
||||||
|
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
|
||||||
|
for fn in sorted(filenames):
|
||||||
|
consider(Path(dirpath) / fn)
|
||||||
|
else:
|
||||||
|
for entry in sorted(base.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
consider(entry)
|
||||||
|
|
||||||
|
return sorted(found, key=str.lower)
|
||||||
+19
-7
@@ -25,13 +25,24 @@ def apply_pipeline(
|
|||||||
index: int,
|
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:
|
||||||
|
|||||||
+71
-7
@@ -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):
|
||||||
@@ -201,14 +203,14 @@ class NumberingRule(Rule):
|
|||||||
return stem, ext
|
return stem, ext
|
||||||
|
|
||||||
|
|
||||||
# Episode renumber: match SxxExx or similar, replace episode number only, keep title
|
# Episode renumber: match SxxExx or SxxExx-Eyy, replace episode block; keep title
|
||||||
@dataclass
|
@dataclass
|
||||||
class EpisodeRenumberRule(Rule):
|
class EpisodeRenumberRule(Rule):
|
||||||
start: int = 1
|
start: int = 1
|
||||||
step: int = 1
|
step: int = 1
|
||||||
padding: int = 2
|
padding: int = 2
|
||||||
# Pattern: group 1 = prefix (e.g. "S01E"), group 2 = episode digits, group 3 = rest (title)
|
# Group 1 = prefix (e.g. "S01E"), 2 = first ep digits, 3 = full "-E06" or None, 4 = second ep if range, 5 = rest (title)
|
||||||
pattern: str = r"(.*?[Ss]\d+[Ee])(\d+)(.*)"
|
pattern: str = r"(.*?[Ss]\d+[Ee])(\d+)(-[Ee](\d+))?(.*)"
|
||||||
|
|
||||||
def apply(
|
def apply(
|
||||||
self,
|
self,
|
||||||
@@ -224,10 +226,72 @@ class EpisodeRenumberRule(Rule):
|
|||||||
return stem, ext
|
return stem, ext
|
||||||
if not m:
|
if not m:
|
||||||
return stem, ext
|
return stem, ext
|
||||||
prefix, _old_ep, rest = m.group(1), m.group(2), m.group(3)
|
prefix, old_first_s, range_dash_e, old_second_s, rest = (
|
||||||
ep_num = self.start + index * self.step
|
m.group(1),
|
||||||
ep_str = str(ep_num).zfill(max(1, self.padding))
|
m.group(2),
|
||||||
new_stem = f"{prefix}{ep_str}{rest}"
|
m.group(3),
|
||||||
|
m.group(4),
|
||||||
|
m.group(5),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
old_first = int(old_first_s)
|
||||||
|
except ValueError:
|
||||||
|
return stem, ext
|
||||||
|
span = 1
|
||||||
|
if old_second_s is not None:
|
||||||
|
try:
|
||||||
|
old_second = int(old_second_s)
|
||||||
|
except ValueError:
|
||||||
|
return stem, ext
|
||||||
|
span = old_second - old_first + 1
|
||||||
|
if span < 1:
|
||||||
|
span = 1
|
||||||
|
ep_new_first = self.start + index * self.step
|
||||||
|
pad = max(1, self.padding)
|
||||||
|
e1 = str(ep_new_first).zfill(pad)
|
||||||
|
if span <= 1:
|
||||||
|
new_stem = f"{prefix}{e1}{rest}"
|
||||||
|
else:
|
||||||
|
ep_new_last = ep_new_first + span - 1
|
||||||
|
e2 = str(ep_new_last).zfill(pad)
|
||||||
|
# Match common style: S01E01-E02 (second block uses E again)
|
||||||
|
new_stem = f"{prefix}{e1}-E{e2}{rest}"
|
||||||
|
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
|
return new_stem, ext
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
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"),
|
||||||
|
("Alternate order", "alternate"),
|
||||||
|
("Regional order", "regional"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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 get_season_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season: int,
|
||||||
|
season_type: str = "default",
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
episodes: list[TvdbEpisode] = []
|
||||||
|
page = 0
|
||||||
|
while True:
|
||||||
|
payload = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/series/{series_id}/episodes/{season_type}",
|
||||||
|
params={"page": page, "season": season},
|
||||||
|
)
|
||||||
|
batch = (payload.get("data") or {}).get("episodes") or []
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
for ep in batch:
|
||||||
|
if ep.get("seasonNumber") != season:
|
||||||
|
continue
|
||||||
|
number = ep.get("number")
|
||||||
|
name = ep.get("name")
|
||||||
|
if number is None or not name:
|
||||||
|
continue
|
||||||
|
episodes.append(
|
||||||
|
TvdbEpisode(
|
||||||
|
number=int(number),
|
||||||
|
season_number=int(season),
|
||||||
|
name=str(name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
links = payload.get("links") or {}
|
||||||
|
if not links.get("next"):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
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
|
||||||
+94
-12
@@ -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 rule’s options change
|
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rule’s 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,9 +228,18 @@ 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()
|
||||||
|
|
||||||
@@ -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 doesn’t reorder or clear selection
|
# Preserve sort and selection so checking a rule doesn’t 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()
|
||||||
|
|||||||
+253
-2
@@ -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
|
||||||
|
from engine.episode_match import match_filenames_to_episodes
|
||||||
|
|
||||||
|
|
||||||
class ReplaceRuleWidget(QWidget):
|
class ReplaceRuleWidget(QWidget):
|
||||||
@@ -285,7 +289,10 @@ class EpisodeRenumberRuleWidget(QWidget):
|
|||||||
layout.addRow("First episode number:", self.start)
|
layout.addRow("First episode number:", self.start)
|
||||||
layout.addRow("Step:", self.step)
|
layout.addRow("Step:", self.step)
|
||||||
layout.addRow("Zero-pad width:", self.padding)
|
layout.addRow("Zero-pad width:", self.padding)
|
||||||
info = QLabel("Matches patterns like S01E05 - Title or Show 1x03 - Title. Episode number is replaced; title is kept.")
|
info = QLabel(
|
||||||
|
"Matches S01E05 - Title (single) or S01E05-E06 - Title (two-part). "
|
||||||
|
"Ranges keep their length: S01E01-E02, S01E03-E04, … Order follows the file list / table sort."
|
||||||
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
layout.addRow(info)
|
layout.addRow(info)
|
||||||
|
|
||||||
@@ -302,6 +309,250 @@ 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,
|
||||||
|
):
|
||||||
|
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:
|
||||||
|
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))
|
||||||
|
except Exception as e:
|
||||||
|
self.failed.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
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.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.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 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)
|
||||||
|
|
||||||
|
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 _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
|
||||||
|
self.match_btn.setEnabled(False)
|
||||||
|
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)
|
||||||
|
self._match_worker.start()
|
||||||
|
|
||||||
|
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
|
||||||
|
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()})."
|
||||||
|
if unmatched:
|
||||||
|
msg += f" {len(unmatched)} file(s) unmatched."
|
||||||
|
self.status.setText(msg)
|
||||||
|
self.matchCompleted.emit()
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def _on_match_failed(self, message: str):
|
||||||
|
self.match_btn.setEnabled(True)
|
||||||
|
self.status.setText(f"Match failed: {message}")
|
||||||
|
|
||||||
|
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||||
|
r = TvdbEpisodeRenumberRule(
|
||||||
|
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()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
class PrefixSuffixRuleWidget(QWidget):
|
class PrefixSuffixRuleWidget(QWidget):
|
||||||
ruleChanged = pyqtSignal()
|
ruleChanged = pyqtSignal()
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.6
|
||||||
|
- Support 04x01-style filenames (not just S01E05)
|
||||||
|
- Match all seasons at once (season 0) to fix wrong-season edge cases
|
||||||
|
- Improve title fuzzy matching
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
HSRename 1.0.7
|
||||||
|
- Same features as 1.0.6; unique AppImage size so Gear Lever detects updates
|
||||||
|
- Gear Lever compares file size only; v1.0.5 and v1.0.6 were identical bytes
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.8
|
||||||
|
- Include subfolders option for recursive file listing
|
||||||
|
- File type filter with Video, Audio, Images presets and custom extensions
|
||||||
|
- Rename rules preserve subfolder paths; nested apply/undo supported
|
||||||
@@ -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
|
||||||
Executable
+104
@@ -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}"
|
||||||
Reference in New Issue
Block a user