Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ec7746b8 | ||
|
|
69f759b6ce | ||
|
|
c54ee7876c | ||
|
|
9ba2c68ff9 | ||
|
|
db460fb9b3 | ||
|
|
b3ce276d1b |
Binary file not shown.
+22
-4
@@ -9,11 +9,13 @@ cd "$(dirname "$0")"
|
||||
APP_NAME="HSRename"
|
||||
APPDIR="${APP_NAME}.AppDir"
|
||||
EXE_NAME="$APP_NAME"
|
||||
VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo "")"
|
||||
|
||||
echo "=== Installing build deps ==="
|
||||
pip install -q -r requirements.txt -r requirements-build.txt
|
||||
|
||||
echo "=== Running PyInstaller ==="
|
||||
echo "=== Running PyInstaller (clean) ==="
|
||||
rm -rf build dist HSRename.spec
|
||||
pyinstaller --noconfirm --onedir --windowed \
|
||||
-n "$EXE_NAME" \
|
||||
--hidden-import=engine \
|
||||
@@ -30,6 +32,17 @@ echo "=== Creating AppDir ==="
|
||||
rm -rf "$APPDIR"
|
||||
mkdir -p "$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.
|
||||
IFS=. read -r VER_MAJOR VER_MINOR VER_PATCH <<< "${VERSION}.0.0"
|
||||
PAD_KB=$((300 + VER_MAJOR * 100 + VER_MINOR * 20 + VER_PATCH * 10))
|
||||
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.release-pad" bs=1024 count="$PAD_KB" status=none
|
||||
echo "Release padding: ${PAD_KB} KiB (Gear Lever update detection)"
|
||||
|
||||
cat > "$APPDIR/AppRun" << EOF
|
||||
#!/bin/sh
|
||||
@@ -39,7 +52,7 @@ exec "\$HERE/usr/bin/$EXE_NAME/$EXE_NAME" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
cat > "$APPDIR/hsrename.desktop" << 'EOF'
|
||||
cat > "$APPDIR/hsrename.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Name=HSRename
|
||||
Comment=Mass rename files with preview and flexible rules
|
||||
@@ -47,6 +60,7 @@ Exec=HSRename
|
||||
Icon=HSRename
|
||||
Type=Application
|
||||
Categories=Utility;FileTools;
|
||||
X-AppImage-Version=${VERSION}
|
||||
EOF
|
||||
# Use project icon as app icon and logo
|
||||
if [[ -f HSRename.png ]]; then
|
||||
@@ -56,7 +70,6 @@ else
|
||||
fi
|
||||
|
||||
echo "=== Building AppImage (requires appimagetool) ==="
|
||||
# Note: appimagetool does not accept plain https URLs for -u; use zsync/gh-releases-zsync per AppImage docs.
|
||||
if command -v appimagetool &>/dev/null; then
|
||||
OUT="${APP_NAME}.AppImage"
|
||||
appimagetool "$APPDIR" "$OUT"
|
||||
@@ -65,5 +78,10 @@ else
|
||||
echo "AppDir is ready at $APPDIR/"
|
||||
echo "To create the .AppImage, install appimagetool and run:"
|
||||
echo " appimagetool $APPDIR ${APP_NAME}.AppImage"
|
||||
echo " # Get it from: https://github.com/AppImage/appimagetool/releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! strings "dist/$EXE_NAME/$EXE_NAME" | grep -q "engine.tvdb_client"; then
|
||||
echo "ERROR: Built executable is missing TheTVDB modules." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+50
-37
@@ -1,58 +1,71 @@
|
||||
# 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:
|
||||
`/owner/repo/releases/download/{tag}/{filename}`
|
||||
In the HSRename details screen:
|
||||
|
||||
1. Open **Gear Lever** and add the HSRename AppImage (drag & drop or **Add**).
|
||||
2. Select the HSRename entry and open **Update** / **Custom update URL** (or the equivalent in your Gear Lever version).
|
||||
3. Try this URL pattern (with a wildcard for the tag):
|
||||
```
|
||||
https://git.hisora.dev/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.
|
||||
- **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.
|
||||
|
||||
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**.
|
||||
2. Paste the **direct download URL** of the current release, for example:
|
||||
- **v1.0.3:**
|
||||
`https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/v1.0.3/HSRename.AppImage`
|
||||
3. When a new version is released (e.g. v1.0.4), open the [releases page](https://git.hisora.dev/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**.
|
||||
4. Save, then use **Check for updates** / **List updates**.
|
||||
|
||||
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 **file size** with your local AppImage.
|
||||
|
||||
```bash
|
||||
# Set custom update URL (try the wildcard pattern)
|
||||
gearlever --set-update-url /path/to/HSRename.AppImage "https://git.hisora.dev/Dawnsorrow/HS-Rename/releases/download/*/HSRename.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.
|
||||
|
||||
# Check for updates
|
||||
gearlever --list-updates
|
||||
To see which version you have without updating: open the AppImage mount folder `usr/share/hsrename/VERSION`, or check **X-AppImage-Version** in Gear Lever metadata after **Reload metadata**.
|
||||
|
||||
# Apply update
|
||||
gearlever --update /path/to/HSRename.AppImage
|
||||
```
|
||||
## Direct download URL pattern
|
||||
|
||||
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:
|
||||
For release tag `vX.Y.Z`:
|
||||
|
||||
```
|
||||
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.4), 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**; 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
|
||||
|
||||
+172
-77
@@ -4,63 +4,149 @@ Match episode titles from filenames against a reference episode list (e.g. TheTV
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional
|
||||
|
||||
from .tvdb_client import TvdbEpisode
|
||||
|
||||
DEFAULT_EPISODE_PATTERN = r"(.*?[Ss]\d+[Ee])(\d+)(-[Ee](\d+))?(.*)"
|
||||
# S01E05 - Title or S01E05-E06 - Title
|
||||
PATTERN_SXXEXX = re.compile(
|
||||
r"^(.*?[Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$",
|
||||
)
|
||||
# Show Name 04x01 Title, 4x01 - Title, etc.
|
||||
PATTERN_NXNN = re.compile(
|
||||
r"^(.*?)(\d{1,2})[xX](\d{1,4})(?:([\s._-]+)(.+))?$",
|
||||
)
|
||||
|
||||
DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpisodeTarget:
|
||||
season: int
|
||||
episode: int
|
||||
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
|
||||
t = title.lower()
|
||||
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", t).strip()
|
||||
t = re.sub(r"\s+", " ", t).strip()
|
||||
if t.startswith("the "):
|
||||
t = t[4:]
|
||||
return t
|
||||
|
||||
|
||||
def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
|
||||
"""
|
||||
Parse SxxExx-style filename stem.
|
||||
Returns dict with prefix, episode numbers, title rest, and span — or None if no match.
|
||||
"""
|
||||
try:
|
||||
m = re.match(pattern, stem)
|
||||
except re.error:
|
||||
return None
|
||||
if not m:
|
||||
return None
|
||||
prefix, old_first_s, _range_dash_e, old_second_s, rest = (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
m.group(3),
|
||||
m.group(4),
|
||||
m.group(5),
|
||||
)
|
||||
try:
|
||||
old_first = int(old_first_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = 1
|
||||
if old_second_s is not None:
|
||||
try:
|
||||
old_second = int(old_second_s)
|
||||
except ValueError:
|
||||
return None
|
||||
span = old_second - old_first + 1
|
||||
if span < 1:
|
||||
span = 1
|
||||
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 {
|
||||
"prefix": prefix,
|
||||
"old_first": old_first,
|
||||
"span": span,
|
||||
"title": title,
|
||||
}
|
||||
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(
|
||||
@@ -69,25 +155,16 @@ def rewrite_episode_number(
|
||||
padding: int = 2,
|
||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||
) -> str:
|
||||
"""Replace episode number block in stem, preserving prefix, span, and title."""
|
||||
"""Legacy helper: episode only, keep season from filename."""
|
||||
parsed = parse_episode_stem(stem, pattern)
|
||||
if not parsed:
|
||||
return stem
|
||||
try:
|
||||
m = re.match(pattern, stem)
|
||||
except re.error:
|
||||
return stem
|
||||
if not m:
|
||||
return stem
|
||||
prefix = m.group(1)
|
||||
rest = m.group(5)
|
||||
span = parsed["span"]
|
||||
pad = max(1, padding)
|
||||
e1 = str(new_first_ep).zfill(pad)
|
||||
if span <= 1:
|
||||
return f"{prefix}{e1}{rest}"
|
||||
e2 = str(new_first_ep + span - 1).zfill(pad)
|
||||
return f"{prefix}{e1}-E{e2}{rest}"
|
||||
return rewrite_episode_stem(
|
||||
stem,
|
||||
EpisodeTarget(season=parsed["season"], episode=new_first_ep),
|
||||
padding=padding,
|
||||
pattern=pattern,
|
||||
)
|
||||
|
||||
|
||||
def _similarity(a: str, b: str) -> float:
|
||||
@@ -98,55 +175,73 @@ def _similarity(a: str, b: str) -> float:
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def _coerce_target(value: EpisodeTarget | tuple[int, int] | int, parsed: dict) -> EpisodeTarget:
|
||||
if isinstance(value, EpisodeTarget):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return EpisodeTarget(season=int(value[0]), episode=int(value[1]))
|
||||
return EpisodeTarget(season=parsed["season"], episode=int(value))
|
||||
|
||||
|
||||
def match_filenames_to_episodes(
|
||||
filenames: list[str],
|
||||
episodes: list[TvdbEpisode],
|
||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||
min_score: float = 0.72,
|
||||
) -> tuple[dict[str, int], list[str], list[str]]:
|
||||
min_score: float = 0.65,
|
||||
season_filter: int = 0,
|
||||
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
|
||||
"""
|
||||
Match filenames to TheTVDB episode numbers by title.
|
||||
Match filenames to TheTVDB episodes by title.
|
||||
|
||||
Returns:
|
||||
mapping: original filename -> correct episode number
|
||||
unmatched_files: filenames that could not be matched
|
||||
notes: human-readable match details for UI
|
||||
season_filter: 0 = use all episodes; else only episodes from that season.
|
||||
Returns mapping filename -> (season, episode), unmatched list, notes.
|
||||
"""
|
||||
file_entries: list[tuple[str, str, str]] = []
|
||||
if season_filter > 0:
|
||||
episodes = [ep for ep in episodes if ep.season_number == season_filter]
|
||||
|
||||
file_entries: list[tuple[str, str, str, dict]] = []
|
||||
for name in filenames:
|
||||
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
||||
parsed = parse_episode_stem(stem, pattern)
|
||||
if not parsed or not parsed["title"]:
|
||||
continue
|
||||
if season_filter > 0 and parsed["season"] != season_filter:
|
||||
continue
|
||||
norm = normalize_title(parsed["title"])
|
||||
if norm:
|
||||
file_entries.append((name, norm, parsed["title"]))
|
||||
file_entries.append((name, norm, parsed["title"], parsed))
|
||||
|
||||
ep_entries = [(ep.number, normalize_title(ep.name), ep.name) for ep in episodes]
|
||||
ep_entries = [
|
||||
(ep.season_number, ep.number, normalize_title(ep.name), ep.name)
|
||||
for ep in episodes
|
||||
]
|
||||
|
||||
pairs: list[tuple[float, str, int, str, str]] = []
|
||||
for fname, fnorm, raw_title in file_entries:
|
||||
for ep_num, enorm, ep_name in ep_entries:
|
||||
pairs: list[tuple[float, str, int, int, str, str]] = []
|
||||
for fname, fnorm, raw_title, _parsed in file_entries:
|
||||
for season, ep_num, enorm, ep_name in ep_entries:
|
||||
score = _similarity(fnorm, enorm)
|
||||
pairs.append((score, fname, ep_num, raw_title, ep_name))
|
||||
pairs.append((score, fname, season, ep_num, raw_title, ep_name))
|
||||
|
||||
pairs.sort(key=lambda x: (-x[0], x[1], x[2]))
|
||||
pairs.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
||||
|
||||
mapping: dict[str, int] = {}
|
||||
mapping: dict[str, EpisodeTarget] = {}
|
||||
used_files: set[str] = set()
|
||||
used_eps: set[int] = set()
|
||||
used_eps: set[tuple[int, int]] = set()
|
||||
notes: list[str] = []
|
||||
|
||||
for score, fname, ep_num, raw_title, ep_name in pairs:
|
||||
for score, fname, season, ep_num, raw_title, ep_name in pairs:
|
||||
if score < min_score:
|
||||
break
|
||||
if fname in used_files or ep_num in used_eps:
|
||||
ep_key = (season, ep_num)
|
||||
if fname in used_files or ep_key in used_eps:
|
||||
continue
|
||||
mapping[fname] = ep_num
|
||||
mapping[fname] = EpisodeTarget(season=season, episode=ep_num)
|
||||
used_files.add(fname)
|
||||
used_eps.add(ep_num)
|
||||
used_eps.add(ep_key)
|
||||
pct = int(round(score * 100))
|
||||
notes.append(f"{fname}: E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)")
|
||||
notes.append(
|
||||
f"{fname}: S{season:02d}E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
|
||||
)
|
||||
|
||||
unmatched_files = [
|
||||
name for name in filenames
|
||||
|
||||
+9
-5
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .episode_match import rewrite_episode_number
|
||||
from .episode_match import rewrite_episode_stem, EpisodeTarget, _coerce_target, parse_episode_stem
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -279,12 +279,16 @@ class TvdbEpisodeRenumberRule(Rule):
|
||||
) -> tuple[str, str]:
|
||||
if not self.episode_mapping or original_name is None:
|
||||
return stem, ext
|
||||
new_ep = self.episode_mapping.get(original_name)
|
||||
if new_ep is None:
|
||||
target_raw = self.episode_mapping.get(original_name)
|
||||
if target_raw is None:
|
||||
return stem, ext
|
||||
new_stem = rewrite_episode_number(
|
||||
parsed = parse_episode_stem(stem, self.pattern)
|
||||
if not parsed:
|
||||
return stem, ext
|
||||
target = _coerce_target(target_raw, parsed)
|
||||
new_stem = rewrite_episode_stem(
|
||||
stem,
|
||||
int(new_ep),
|
||||
target,
|
||||
padding=self.padding,
|
||||
pattern=self.pattern,
|
||||
)
|
||||
|
||||
+68
-5
@@ -4,6 +4,7 @@ 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
|
||||
@@ -42,6 +43,29 @@ 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()
|
||||
@@ -112,11 +136,11 @@ class TvdbClient:
|
||||
)
|
||||
results: list[TvdbSeries] = []
|
||||
for item in payload.get("data") or []:
|
||||
if item.get("type") != "series":
|
||||
item_type = item.get("type") or item.get("primary_type")
|
||||
if item_type and item_type != "series":
|
||||
continue
|
||||
try:
|
||||
series_id = int(item["id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
series_id = _parse_series_id(item)
|
||||
if series_id is None:
|
||||
continue
|
||||
year = item.get("year")
|
||||
if year is not None:
|
||||
@@ -165,5 +189,44 @@ class TvdbClient:
|
||||
if not links.get("next"):
|
||||
break
|
||||
page += 1
|
||||
episodes.sort(key=lambda e: e.number)
|
||||
episodes.sort(key=lambda e: (e.season_number, e.number))
|
||||
return episodes
|
||||
|
||||
def get_all_episodes(
|
||||
self,
|
||||
series_id: int,
|
||||
season_type: str = "default",
|
||||
) -> list[TvdbEpisode]:
|
||||
"""Fetch every episode for a series (all seasons), paginated."""
|
||||
episodes: list[TvdbEpisode] = []
|
||||
page = 0
|
||||
while True:
|
||||
payload = self._request(
|
||||
"GET",
|
||||
f"/series/{series_id}/episodes/{season_type}",
|
||||
params={"page": page},
|
||||
)
|
||||
batch = (payload.get("data") or {}).get("episodes") or []
|
||||
if not batch:
|
||||
break
|
||||
for ep in batch:
|
||||
number = ep.get("number")
|
||||
season = ep.get("seasonNumber")
|
||||
name = ep.get("name")
|
||||
if number is None or season is None or not name:
|
||||
continue
|
||||
if int(season) == 0:
|
||||
continue
|
||||
episodes.append(
|
||||
TvdbEpisode(
|
||||
number=int(number),
|
||||
season_number=int(season),
|
||||
name=str(name),
|
||||
)
|
||||
)
|
||||
links = payload.get("links") or {}
|
||||
if not links.get("next"):
|
||||
break
|
||||
page += 1
|
||||
episodes.sort(key=lambda e: (e.season_number, e.number))
|
||||
return episodes
|
||||
|
||||
+39
-17
@@ -338,24 +338,39 @@ class _TvdbMatchWorker(QThread):
|
||||
season: int,
|
||||
season_type: str,
|
||||
filenames: list[str],
|
||||
all_seasons: bool,
|
||||
):
|
||||
super().__init__()
|
||||
self.series_id = series_id
|
||||
self.season = season
|
||||
self.season_type = season_type
|
||||
self.filenames = filenames
|
||||
self.all_seasons = all_seasons
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = TvdbClient(TVDB_API_KEY)
|
||||
episodes = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type=self.season_type,
|
||||
)
|
||||
if self.all_seasons:
|
||||
episodes = client.get_all_episodes(
|
||||
self.series_id,
|
||||
season_type=self.season_type,
|
||||
)
|
||||
season_filter = 0
|
||||
else:
|
||||
episodes = client.get_season_episodes(
|
||||
self.series_id,
|
||||
self.season,
|
||||
season_type=self.season_type,
|
||||
)
|
||||
season_filter = self.season
|
||||
if not episodes:
|
||||
raise TvdbError(f"No episodes found for season {self.season}")
|
||||
mapping, unmatched, notes = match_filenames_to_episodes(self.filenames, episodes)
|
||||
label = "all seasons" if self.all_seasons else f"season {self.season}"
|
||||
raise TvdbError(f"No episodes found for {label}")
|
||||
mapping, unmatched, notes = match_filenames_to_episodes(
|
||||
self.filenames,
|
||||
episodes,
|
||||
season_filter=season_filter,
|
||||
)
|
||||
self.finished.emit(mapping, unmatched, notes)
|
||||
except TvdbError as e:
|
||||
self.failed.emit(str(e))
|
||||
@@ -407,7 +422,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.season = QSpinBox()
|
||||
self.season.setMinimum(0)
|
||||
self.season.setMaximum(99)
|
||||
self.season.setValue(1)
|
||||
self.season.setValue(0)
|
||||
self.season.setSpecialValueText("All seasons")
|
||||
self.season.valueChanged.connect(self._emit)
|
||||
layout.addRow("Season:", self.season)
|
||||
|
||||
@@ -422,13 +438,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.match_btn.clicked.connect(self._match_titles)
|
||||
layout.addRow(self.match_btn)
|
||||
|
||||
self.status = QLabel("Search for a show, choose episode order and season, then match titles.")
|
||||
self.status = QLabel("Search for a show, choose episode order, then match titles (0 = all seasons).")
|
||||
self.status.setWordWrap(True)
|
||||
layout.addRow(self.status)
|
||||
|
||||
info = QLabel(
|
||||
"Reads episode titles from filenames like S01E05 - Episode Title. "
|
||||
"Fetches the chosen TheTVDB episode order for that season and rewrites SxxExx numbers to match."
|
||||
"Reads titles from S01E05 - Episode Title or Show 04x01 Episode Title filenames. "
|
||||
"Use All seasons to match across every season and fix wrong season numbers too."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
layout.addRow(info)
|
||||
@@ -492,12 +508,17 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
if series_id is None:
|
||||
return
|
||||
self.match_btn.setEnabled(False)
|
||||
self.status.setText(f"Fetching episodes ({self._order_label()}) and matching titles…")
|
||||
all_seasons = self.season.value() == 0
|
||||
self.status.setText(
|
||||
f"Fetching episodes ({self._order_label()}, "
|
||||
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
|
||||
)
|
||||
self._match_worker = _TvdbMatchWorker(
|
||||
int(series_id),
|
||||
self.season.value(),
|
||||
self._order_value(),
|
||||
self._file_names,
|
||||
all_seasons,
|
||||
)
|
||||
self._match_worker.finished.connect(self._on_match_finished)
|
||||
self._match_worker.failed.connect(self._on_match_failed)
|
||||
@@ -508,10 +529,8 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
self.match_btn.setEnabled(True)
|
||||
matched = len(mapping)
|
||||
total = len(self._file_names)
|
||||
msg = (
|
||||
f"Matched {matched} of {total} file(s) for season {self.season.value()} "
|
||||
f"({self._order_label()})."
|
||||
)
|
||||
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
|
||||
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()})."
|
||||
if unmatched:
|
||||
msg += f" {len(unmatched)} file(s) unmatched."
|
||||
self.status.setText(msg)
|
||||
@@ -524,7 +543,10 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||
|
||||
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||
r = TvdbEpisodeRenumberRule(
|
||||
episode_mapping=dict(self._episode_mapping),
|
||||
episode_mapping={
|
||||
k: (v.season, v.episode) if hasattr(v, "season") else v
|
||||
for k, v in self._episode_mapping.items()
|
||||
},
|
||||
padding=self.padding.value(),
|
||||
)
|
||||
r.enabled = self.enabled_cb.isChecked()
|
||||
|
||||
@@ -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
|
||||
+52
-22
@@ -2,33 +2,59 @@
|
||||
# Push tag to Gitea and publish HSRename.AppImage as a release asset.
|
||||
# Usage:
|
||||
# GITEA_TOKEN=your_token ./release_gitea.sh
|
||||
# Optional: ./release_gitea.sh --build (rebuild AppImage first)
|
||||
#
|
||||
# Requires: curl, git, and network access to your Gitea instance.
|
||||
# Always rebuilds the AppImage before upload so releases never ship a stale binary.
|
||||
#
|
||||
# Requires: curl, git, appimagetool, and network access to your Gitea instance.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
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}"
|
||||
|
||||
if [[ "${1:-}" == "--build" ]]; then
|
||||
export PATH="/tmp:${PATH}"
|
||||
if ! command -v appimagetool &>/dev/null && [[ -x /tmp/appimagetool ]]; then
|
||||
export PATH="/tmp:${PATH}"
|
||||
verify_appimage() {
|
||||
if [[ ! -f "$APP_IMAGE" ]]; then
|
||||
echo "Missing $APP_IMAGE after build." >&2
|
||||
exit 1
|
||||
fi
|
||||
./build_appimage.sh
|
||||
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
|
||||
|
||||
if [[ ! -f "$APP_IMAGE" ]]; then
|
||||
echo "Missing $APP_IMAGE. Run ./build_appimage.sh or ./release_gitea.sh --build"
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Building fresh AppImage for ${TAG} ==="
|
||||
./build_appimage.sh
|
||||
verify_appimage
|
||||
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG not found. Create it with: git tag $TAG"
|
||||
@@ -41,10 +67,8 @@ git push origin "$TAG"
|
||||
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo ""
|
||||
echo "Code and tag pushed. To upload the AppImage, set GITEA_TOKEN and re-run:"
|
||||
echo "Built and verified $APP_IMAGE. Set GITEA_TOKEN and re-run to upload:"
|
||||
echo " GITEA_TOKEN=... ./release_gitea.sh"
|
||||
echo ""
|
||||
echo "Or create the release manually on Gitea and attach $APP_IMAGE."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -52,21 +76,27 @@ API="${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases"
|
||||
NOTES="$(cat <<EOF
|
||||
## HSRename ${TAG}
|
||||
|
||||
- TheTVDB episode title matching: fix SxxExx numbering against TheTVDB order (default, aired, DVD, etc.)
|
||||
- Episode renumbering and multi-episode ranges unchanged
|
||||
- Fix botched v1.0.4 AppImage (restores TheTVDB episode match + search fix)
|
||||
- Always verify/update via Gear Lever Forgejo: \`https://git.hisora.dev/Dawnsorrow/HS-Rename\`
|
||||
|
||||
Gear Lever update URL:
|
||||
Download:
|
||||
\`${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${TAG}/${APP_IMAGE}\`
|
||||
EOF
|
||||
)"
|
||||
|
||||
echo "=== Creating Gitea release $TAG ==="
|
||||
curl -fsS -X POST "$API" \
|
||||
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 "title=${TAG}" \
|
||||
-F "body=${NOTES}" \
|
||||
-F "attachment=@${APP_IMAGE};filename=${APP_IMAGE}"
|
||||
-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:"
|
||||
|
||||
Reference in New Issue
Block a user