Compare commits
8
Commits
59dba20c1e
...
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f759b6ce | ||
|
|
c54ee7876c | ||
|
|
9ba2c68ff9 | ||
|
|
db460fb9b3 | ||
|
|
b3ce276d1b | ||
|
|
682e76de2f | ||
|
|
70a0ff0e84 | ||
|
|
085d6dc296 |
Binary file not shown.
+18
-4
@@ -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,11 @@ 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
|
||||||
|
|
||||||
cat > "$APPDIR/AppRun" << EOF
|
cat > "$APPDIR/AppRun" << EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -37,7 +46,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 +54,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 +64,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 +72,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
|
||||||
|
|||||||
+49
-40
@@ -1,58 +1,67 @@
|
|||||||
# 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, right‑click **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 release’s 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 size with your local AppImage.
|
||||||
|
|
||||||
```bash
|
## Direct download URL pattern
|
||||||
# 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
|
For release tag `vX.Y.Z`:
|
||||||
gearlever --list-updates
|
|
||||||
|
|
||||||
# Apply update
|
|
||||||
gearlever --update /path/to/HSRename.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `/path/to/HSRename.AppImage` with the actual path where Gear Lever stores the AppImage.
|
|
||||||
|
|
||||||
## 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 tag’s 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**, or local file is already the latest release (same size as `releases/latest`). |
|
||||||
|
| 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,254 @@
|
|||||||
|
"""
|
||||||
|
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 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_before_s, season_s, _e, ep_s, _dash, ep2_s, rest = m.groups()
|
||||||
|
try:
|
||||||
|
season = int(season_s)
|
||||||
|
old_first = int(ep_s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
span = 1
|
||||||
|
if ep2_s is not None:
|
||||||
|
try:
|
||||||
|
old_second = int(ep2_s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
span = old_second - old_first + 1
|
||||||
|
if span < 1:
|
||||||
|
span = 1
|
||||||
|
title = _clean_title(rest)
|
||||||
|
return {
|
||||||
|
"format": "sxxexx",
|
||||||
|
"prefix_before_s": prefix_before_s,
|
||||||
|
"season": season,
|
||||||
|
"season_pad": len(season_s),
|
||||||
|
"episode_pad": len(ep_s),
|
||||||
|
"old_first": old_first,
|
||||||
|
"span": span,
|
||||||
|
"title": title,
|
||||||
|
"suffix": rest,
|
||||||
|
}
|
||||||
|
|
||||||
|
m = PATTERN_NXNN.match(stem)
|
||||||
|
if m:
|
||||||
|
prefix, season_s, ep_s, sep, title_part = m.groups()
|
||||||
|
try:
|
||||||
|
season = int(season_s)
|
||||||
|
episode = int(ep_s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
title = _clean_title(title_part or "")
|
||||||
|
sep = sep or " "
|
||||||
|
if title and not sep.strip():
|
||||||
|
sep = " "
|
||||||
|
return {
|
||||||
|
"format": "nxnn",
|
||||||
|
"prefix": prefix,
|
||||||
|
"season": season,
|
||||||
|
"season_pad": len(season_s),
|
||||||
|
"episode_pad": len(ep_s),
|
||||||
|
"old_first": episode,
|
||||||
|
"span": 1,
|
||||||
|
"title": title,
|
||||||
|
"title_sep": sep,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_episode_stem(
|
||||||
|
stem: str,
|
||||||
|
target: EpisodeTarget,
|
||||||
|
padding: int = 2,
|
||||||
|
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||||
|
) -> str:
|
||||||
|
"""Replace season/episode block in stem, preserving layout and title."""
|
||||||
|
parsed = parse_episode_stem(stem, pattern)
|
||||||
|
if not parsed:
|
||||||
|
return stem
|
||||||
|
|
||||||
|
pad = max(1, padding)
|
||||||
|
new_season = target.season
|
||||||
|
new_ep = target.episode
|
||||||
|
title = parsed["title"]
|
||||||
|
span = parsed["span"]
|
||||||
|
|
||||||
|
if parsed["format"] == "nxnn":
|
||||||
|
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||||
|
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||||
|
block = f"{new_season:0{s_pad}d}x{new_ep:0{e_pad}d}"
|
||||||
|
if title:
|
||||||
|
return f"{parsed['prefix']}{block}{parsed['title_sep']}{title}"
|
||||||
|
return f"{parsed['prefix']}{block}"
|
||||||
|
|
||||||
|
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||||
|
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||||
|
e1 = str(new_ep).zfill(e_pad)
|
||||||
|
prefix = f"{parsed['prefix_before_s']}S{new_season:0{s_pad}d}E"
|
||||||
|
if span <= 1:
|
||||||
|
return f"{prefix}{e1}{parsed['suffix']}"
|
||||||
|
e2 = str(new_ep + span - 1).zfill(e_pad)
|
||||||
|
return f"{prefix}{e1}-E{e2}{parsed['suffix']}"
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_episode_number(
|
||||||
|
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:
|
||||||
|
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
||||||
|
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(
|
||||||
|
name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name,
|
||||||
|
pattern,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return mapping, unmatched_files, notes
|
||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
+15
-2
@@ -36,6 +36,7 @@ from .rule_widgets import (
|
|||||||
CaseRuleWidget,
|
CaseRuleWidget,
|
||||||
NumberingRuleWidget,
|
NumberingRuleWidget,
|
||||||
EpisodeRenumberRuleWidget,
|
EpisodeRenumberRuleWidget,
|
||||||
|
TvdbEpisodeRenumberRuleWidget,
|
||||||
PrefixSuffixRuleWidget,
|
PrefixSuffixRuleWidget,
|
||||||
CsvMappingRuleWidget,
|
CsvMappingRuleWidget,
|
||||||
)
|
)
|
||||||
@@ -85,6 +86,7 @@ class MainWindow(QMainWindow):
|
|||||||
CaseRuleWidget(),
|
CaseRuleWidget(),
|
||||||
NumberingRuleWidget(),
|
NumberingRuleWidget(),
|
||||||
EpisodeRenumberRuleWidget(),
|
EpisodeRenumberRuleWidget(),
|
||||||
|
TvdbEpisodeRenumberRuleWidget(),
|
||||||
PrefixSuffixRuleWidget(),
|
PrefixSuffixRuleWidget(),
|
||||||
CsvMappingRuleWidget(),
|
CsvMappingRuleWidget(),
|
||||||
]
|
]
|
||||||
@@ -96,12 +98,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)
|
||||||
@@ -206,7 +213,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()
|
||||||
|
|||||||
+249
-1
@@ -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):
|
||||||
@@ -305,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
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