Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87282b0259 | ||
|
|
64d7a30328 | ||
|
|
445e24ecae | ||
|
|
fce5ad64e5 | ||
|
|
a6339467d4 | ||
|
|
a65ae73bcc | ||
|
|
85dc873e16 | ||
|
|
1c475ee856 | ||
|
|
69f759b6ce | ||
|
|
c54ee7876c | ||
|
|
9ba2c68ff9 |
Binary file not shown.
+18
-10
@@ -14,7 +14,8 @@ 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 \
|
||||||
@@ -36,13 +37,16 @@ echo "$VERSION" > "$APPDIR/usr/share/hsrename/VERSION"
|
|||||||
if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
if [[ -f "packaging/release-stamp-${VERSION}.txt" ]]; then
|
||||||
cp "packaging/release-stamp-${VERSION}.txt" "$APPDIR/usr/share/hsrename/release-stamp.txt"
|
cp "packaging/release-stamp-${VERSION}.txt" "$APPDIR/usr/share/hsrename/release-stamp.txt"
|
||||||
fi
|
fi
|
||||||
# Gear Lever compares file sizes; incompressible pad for patch releases (zeros squash to nothing).
|
# Gear Lever (Forgejo) treats same-size AppImages as "no update". Version-scoped
|
||||||
dd if=/dev/urandom of="$APPDIR/usr/share/hsrename/.buildpad" bs=1024 count=300 status=none
|
# padding keeps each release a unique download size without affecting the app.
|
||||||
cat > "$APPDIR/usr/share/hsrename/changelog.txt" << EOF
|
VER_MAJOR="${VERSION%%.*}"
|
||||||
HSRename ${VERSION}
|
VER_REST="${VERSION#*.}"
|
||||||
- Fix TheTVDB search dropping results (parse series-76320 style IDs)
|
VER_MINOR="${VER_REST%%.*}"
|
||||||
- Gear Lever Forgejo update docs
|
VER_PATCH="${VER_REST#*.}"
|
||||||
EOF
|
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
|
||||||
@@ -70,7 +74,6 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== Building AppImage (requires appimagetool) ==="
|
echo "=== Building AppImage (requires appimagetool) ==="
|
||||||
# Note: appimagetool does not accept plain https URLs for -u; use zsync/gh-releases-zsync per AppImage docs.
|
|
||||||
if command -v appimagetool &>/dev/null; then
|
if command -v appimagetool &>/dev/null; then
|
||||||
OUT="${APP_NAME}.AppImage"
|
OUT="${APP_NAME}.AppImage"
|
||||||
appimagetool "$APPDIR" "$OUT"
|
appimagetool "$APPDIR" "$OUT"
|
||||||
@@ -79,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
|
||||||
|
|||||||
+6
-2
@@ -28,7 +28,11 @@ If the fields validate (no error), Gear Lever queries:
|
|||||||
|
|
||||||
`https://git.hisora.dev/api/v1/repos/Dawnsorrow/HS-Rename/releases/latest`
|
`https://git.hisora.dev/api/v1/repos/Dawnsorrow/HS-Rename/releases/latest`
|
||||||
|
|
||||||
and compares the release asset size with your local AppImage.
|
and compares the release asset **file size** with your local AppImage.
|
||||||
|
|
||||||
|
Gear Lever does **not** compare version numbers or file contents. If two releases compress to the same byte size (common with AppImages), it will say “No updates available” even when the app changed. HSRename releases from v1.0.7 onward include a small version-specific padding file so each release has a unique size.
|
||||||
|
|
||||||
|
To see which version you have without updating: open the AppImage mount folder `usr/share/hsrename/VERSION`, or check **X-AppImage-Version** in Gear Lever metadata after **Reload metadata**.
|
||||||
|
|
||||||
## Direct download URL pattern
|
## Direct download URL pattern
|
||||||
|
|
||||||
@@ -60,7 +64,7 @@ Gear Lever 4 may use different CLI options than older docs. Prefer the GUI Forge
|
|||||||
|
|
||||||
| Symptom | Likely cause |
|
| Symptom | Likely cause |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| No update offered | Source is still **Default**, or local file is already the latest release (same size as `releases/latest`). |
|
| No update offered | Source is still **Default**; local file size matches `releases/latest` (Gear Lever ignores version/content); or wrong Forgejo repo URL. |
|
||||||
| URL looks truncated in Website | Website is cosmetic only; configure **Forgejo** fields instead. |
|
| URL looks truncated in Website | Website is cosmetic only; configure **Forgejo** fields instead. |
|
||||||
| Update never finds newer builds | Wrong repo URL, wrong filename, or release has no `HSRename.AppImage` asset attached. |
|
| Update never finds newer builds | Wrong repo URL, wrong filename, or release has no `HSRename.AppImage` asset attached. |
|
||||||
|
|
||||||
|
|||||||
+400
-70
@@ -4,64 +4,193 @@ Match episode titles from filenames against a reference episode list (e.g. TheTV
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .tvdb_client import TvdbEpisode
|
from .tvdb_client import TvdbEpisode
|
||||||
|
|
||||||
DEFAULT_EPISODE_PATTERN = r"(.*?[Ss]\d+[Ee])(\d+)(-[Ee](\d+))?(.*)"
|
# S01E05 - Title or S01E05-E06 - Title
|
||||||
|
PATTERN_SXXEXX = re.compile(
|
||||||
|
r"^(.*?)([Ss])(\d+)([Ee])(\d+)(-[Ee](\d+))?(.*)$",
|
||||||
|
)
|
||||||
|
# Show Name 04x01 Title, 4x01 - Title, etc.
|
||||||
|
PATTERN_NXNN = re.compile(
|
||||||
|
r"^(.*?)(\d{1,2})[xX](\d{1,4})(?:([\s._-]+)(.+))?$",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_EPISODE_PATTERN = PATTERN_SXXEXX.pattern
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EpisodeTarget:
|
||||||
|
season: int
|
||||||
|
episode: int
|
||||||
|
episode_end: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def span(self) -> int:
|
||||||
|
if self.episode_end is not None and self.episode_end > self.episode:
|
||||||
|
return self.episode_end - self.episode + 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def format_code(self, padding: int = 2) -> str:
|
||||||
|
pad = max(1, padding)
|
||||||
|
s = str(self.season).zfill(pad)
|
||||||
|
e1 = str(self.episode).zfill(pad)
|
||||||
|
if self.episode_end is not None and self.episode_end > self.episode:
|
||||||
|
e2 = str(self.episode_end).zfill(pad)
|
||||||
|
return f"S{s}E{e1}-E{e2}"
|
||||||
|
return f"S{s}E{e1}"
|
||||||
|
|
||||||
|
|
||||||
|
def target_to_tuple(target: EpisodeTarget) -> tuple[int, ...]:
|
||||||
|
"""Plain tuple safe to pass through Qt signals."""
|
||||||
|
if target.episode_end is not None and target.episode_end > target.episode:
|
||||||
|
return (target.season, target.episode, target.episode_end)
|
||||||
|
return (target.season, target.episode)
|
||||||
|
|
||||||
|
|
||||||
|
def split_combined_title(name: str) -> list[str]:
|
||||||
|
"""Split a combined-order episode title like 'Ep A/Ep B' into parts."""
|
||||||
|
return [p.strip() for p in name.replace(" / ", "/").split("/") if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
def normalize_title(title: str) -> str:
|
def normalize_title(title: str) -> str:
|
||||||
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
|
"""Lowercase, strip punctuation, collapse whitespace for fuzzy comparison."""
|
||||||
t = title.lower()
|
t = title.lower()
|
||||||
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
|
t = re.sub(r"[^\w\s]", " ", t, flags=re.UNICODE)
|
||||||
return re.sub(r"\s+", " ", t).strip()
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
if t.startswith("the "):
|
||||||
|
t = t[4:]
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_title(rest: str) -> str:
|
||||||
|
title = rest.strip()
|
||||||
|
for prefix in ("- ", "– ", "_ ", ". "):
|
||||||
|
if title.startswith(prefix):
|
||||||
|
title = title[len(prefix) :].strip()
|
||||||
|
if title.startswith("-") or title.startswith("–"):
|
||||||
|
title = title[1:].strip()
|
||||||
|
if title.startswith("_"):
|
||||||
|
title = title[1:].strip()
|
||||||
|
title = re.sub(
|
||||||
|
r"\(\s*(?:2160p|1080p|720p|480p|4k|uhd|hd|sd|web[- ]?dl|bluray|dvdrip)\s*\)",
|
||||||
|
"",
|
||||||
|
title,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
title = re.sub(r"\s+", " ", title).strip()
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
|
def parse_episode_stem(stem: str, pattern: str = DEFAULT_EPISODE_PATTERN) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Parse SxxExx-style filename stem.
|
Parse common TV filename stems (S01E05 or 04x01 styles).
|
||||||
Returns dict with prefix, episode numbers, title rest, and span — or None if no match.
|
Returns dict with format, season, episode, title, padding hints — or None.
|
||||||
"""
|
"""
|
||||||
|
del pattern # legacy param; auto-detect formats instead
|
||||||
|
|
||||||
|
m = PATTERN_SXXEXX.match(stem)
|
||||||
|
if m:
|
||||||
|
prefix, s_letter, season_s, e_letter, ep_s, _range_block, ep2_s, rest = m.groups()
|
||||||
try:
|
try:
|
||||||
m = re.match(pattern, stem)
|
season = int(season_s)
|
||||||
except re.error:
|
old_first = int(ep_s)
|
||||||
return None
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
prefix, old_first_s, _range_dash_e, old_second_s, rest = (
|
|
||||||
m.group(1),
|
|
||||||
m.group(2),
|
|
||||||
m.group(3),
|
|
||||||
m.group(4),
|
|
||||||
m.group(5),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
old_first = int(old_first_s)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
span = 1
|
span = 1
|
||||||
if old_second_s is not None:
|
if ep2_s is not None:
|
||||||
try:
|
try:
|
||||||
old_second = int(old_second_s)
|
old_second = int(ep2_s)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
span = old_second - old_first + 1
|
span = old_second - old_first + 1
|
||||||
if span < 1:
|
if span < 1:
|
||||||
span = 1
|
span = 1
|
||||||
title = rest.strip()
|
title = _clean_title(rest)
|
||||||
if title.startswith("-") or title.startswith("–"):
|
|
||||||
title = title[1:].strip()
|
|
||||||
if title.startswith("_"):
|
|
||||||
title = title[1:].strip()
|
|
||||||
return {
|
return {
|
||||||
|
"format": "sxxexx",
|
||||||
"prefix": prefix,
|
"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,
|
"old_first": old_first,
|
||||||
"span": span,
|
"span": span,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
"suffix": rest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m = PATTERN_NXNN.match(stem)
|
||||||
|
if m:
|
||||||
|
prefix, season_s, ep_s, sep, title_part = m.groups()
|
||||||
|
try:
|
||||||
|
season = int(season_s)
|
||||||
|
episode = int(ep_s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
title = _clean_title(title_part or "")
|
||||||
|
sep = sep or " "
|
||||||
|
if title and not sep.strip():
|
||||||
|
sep = " "
|
||||||
|
return {
|
||||||
|
"format": "nxnn",
|
||||||
|
"prefix": prefix,
|
||||||
|
"season": season,
|
||||||
|
"season_pad": len(season_s),
|
||||||
|
"episode_pad": len(ep_s),
|
||||||
|
"old_first": episode,
|
||||||
|
"span": 1,
|
||||||
|
"title": title,
|
||||||
|
"title_sep": sep,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_episode_stem(
|
||||||
|
stem: str,
|
||||||
|
target: EpisodeTarget,
|
||||||
|
padding: int = 2,
|
||||||
|
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||||
|
) -> str:
|
||||||
|
"""Replace season/episode block in stem, preserving layout and title."""
|
||||||
|
parsed = parse_episode_stem(stem, pattern)
|
||||||
|
if not parsed:
|
||||||
|
return stem
|
||||||
|
|
||||||
|
pad = max(1, padding)
|
||||||
|
new_season = target.season
|
||||||
|
new_ep = target.episode
|
||||||
|
if target.episode_end is not None and target.episode_end > target.episode:
|
||||||
|
span = target.episode_end - target.episode + 1
|
||||||
|
else:
|
||||||
|
span = parsed["span"]
|
||||||
|
|
||||||
|
if parsed["format"] == "nxnn":
|
||||||
|
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||||
|
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||||
|
block = f"{new_season:0{s_pad}d}x{new_ep:0{e_pad}d}"
|
||||||
|
if title:
|
||||||
|
return f"{parsed['prefix']}{block}{parsed['title_sep']}{title}"
|
||||||
|
return f"{parsed['prefix']}{block}"
|
||||||
|
|
||||||
|
s_pad = max(parsed["season_pad"], len(str(new_season)))
|
||||||
|
e_pad = max(parsed["episode_pad"], pad, len(str(new_ep)))
|
||||||
|
e1 = str(new_ep).zfill(e_pad)
|
||||||
|
head = (
|
||||||
|
f"{parsed['prefix']}{parsed['s_letter']}{new_season:0{s_pad}d}{parsed['e_letter']}"
|
||||||
|
)
|
||||||
|
if span <= 1:
|
||||||
|
return f"{head}{e1}{parsed['suffix']}"
|
||||||
|
e2 = str(new_ep + span - 1).zfill(e_pad)
|
||||||
|
range_prefix = f"-{parsed['e_letter']}"
|
||||||
|
return f"{head}{e1}{range_prefix}{e2}{parsed['suffix']}"
|
||||||
|
|
||||||
|
|
||||||
def rewrite_episode_number(
|
def rewrite_episode_number(
|
||||||
stem: str,
|
stem: str,
|
||||||
@@ -69,25 +198,16 @@ def rewrite_episode_number(
|
|||||||
padding: int = 2,
|
padding: int = 2,
|
||||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Replace episode number block in stem, preserving prefix, span, and title."""
|
"""Legacy helper: episode only, keep season from filename."""
|
||||||
parsed = parse_episode_stem(stem, pattern)
|
parsed = parse_episode_stem(stem, pattern)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return stem
|
return stem
|
||||||
try:
|
return rewrite_episode_stem(
|
||||||
m = re.match(pattern, stem)
|
stem,
|
||||||
except re.error:
|
EpisodeTarget(season=parsed["season"], episode=new_first_ep),
|
||||||
return stem
|
padding=padding,
|
||||||
if not m:
|
pattern=pattern,
|
||||||
return stem
|
)
|
||||||
prefix = m.group(1)
|
|
||||||
rest = m.group(5)
|
|
||||||
span = parsed["span"]
|
|
||||||
pad = max(1, padding)
|
|
||||||
e1 = str(new_first_ep).zfill(pad)
|
|
||||||
if span <= 1:
|
|
||||||
return f"{prefix}{e1}{rest}"
|
|
||||||
e2 = str(new_first_ep + span - 1).zfill(pad)
|
|
||||||
return f"{prefix}{e1}-E{e2}{rest}"
|
|
||||||
|
|
||||||
|
|
||||||
def _similarity(a: str, b: str) -> float:
|
def _similarity(a: str, b: str) -> float:
|
||||||
@@ -98,61 +218,271 @@ def _similarity(a: str, b: str) -> float:
|
|||||||
return SequenceMatcher(None, a, b).ratio()
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_target(
|
||||||
|
value: EpisodeTarget | tuple[int, ...] | int,
|
||||||
|
parsed: dict,
|
||||||
|
) -> EpisodeTarget:
|
||||||
|
if isinstance(value, EpisodeTarget):
|
||||||
|
return value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
if len(value) >= 3:
|
||||||
|
return EpisodeTarget(
|
||||||
|
season=int(value[0]),
|
||||||
|
episode=int(value[1]),
|
||||||
|
episode_end=int(value[2]) if value[2] is not None else None,
|
||||||
|
)
|
||||||
|
if len(value) >= 2:
|
||||||
|
return EpisodeTarget(season=int(value[0]), episode=int(value[1]))
|
||||||
|
return EpisodeTarget(season=parsed["season"], episode=int(value[0]))
|
||||||
|
return EpisodeTarget(season=parsed["season"], episode=int(value))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_combined_to_official(
|
||||||
|
combined_ep: TvdbEpisode,
|
||||||
|
official_episodes: list[TvdbEpisode],
|
||||||
|
) -> Optional[EpisodeTarget]:
|
||||||
|
"""Map a combined-order episode to official aired SxxExx(-Exx) numbers."""
|
||||||
|
parts = split_combined_title(combined_ep.name)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
matched: list[int] = []
|
||||||
|
season = combined_ep.season_number
|
||||||
|
season_official = [ep for ep in official_episodes if ep.season_number == season]
|
||||||
|
for part in parts:
|
||||||
|
pn = normalize_title(part)
|
||||||
|
best_num: Optional[int] = None
|
||||||
|
best_score = 0.0
|
||||||
|
for ep in season_official:
|
||||||
|
score = _similarity(pn, normalize_title(ep.name))
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_num = ep.number
|
||||||
|
if best_num is not None and best_score >= 0.72:
|
||||||
|
matched.append(best_num)
|
||||||
|
if not matched:
|
||||||
|
return None
|
||||||
|
start, end = min(matched), max(matched)
|
||||||
|
return EpisodeTarget(
|
||||||
|
season=season,
|
||||||
|
episode=start,
|
||||||
|
episode_end=end if end > start else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_title_variants(name: str) -> list[str]:
|
||||||
|
variants = [normalize_title(name.replace("/", " ")), normalize_title(name)]
|
||||||
|
for part in split_combined_title(name):
|
||||||
|
variants.append(normalize_title(part))
|
||||||
|
return variants
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_match_score(fnorm: str, combined_name: str, file_title: str = "") -> float:
|
||||||
|
variants = _combined_title_variants(combined_name)
|
||||||
|
best = max((_similarity(fnorm, v) for v in variants), default=0.0)
|
||||||
|
if file_title and _dual_title_matches_combined(file_title, combined_name):
|
||||||
|
best = max(best, 0.96)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _dual_title_matches_combined(file_title: str, combined_name: str) -> bool:
|
||||||
|
"""True when the filename lists both stories in a combined-order pair."""
|
||||||
|
file_parts = [p.strip() for p in re.split(r"\s+-\s+", file_title) if p.strip()]
|
||||||
|
combined_parts = split_combined_title(combined_name)
|
||||||
|
if len(file_parts) < 2 or len(combined_parts) < 2:
|
||||||
|
return False
|
||||||
|
matched = 0
|
||||||
|
for fp in file_parts[: len(combined_parts)]:
|
||||||
|
fn = normalize_title(fp)
|
||||||
|
if max(_similarity(fn, normalize_title(cp)) for cp in combined_parts) >= 0.72:
|
||||||
|
matched += 1
|
||||||
|
return matched >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def _combined_allowed_for_file(
|
||||||
|
parsed: dict,
|
||||||
|
target: EpisodeTarget,
|
||||||
|
combined_name: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Treat as multi-episode when the episode tag or dual title fits the aired range."""
|
||||||
|
if target.span <= 1:
|
||||||
|
return True
|
||||||
|
if parsed.get("span", 1) > 1:
|
||||||
|
return True
|
||||||
|
file_title = parsed.get("title") or ""
|
||||||
|
if combined_name and _dual_title_matches_combined(file_title, combined_name):
|
||||||
|
return True
|
||||||
|
file_ep = parsed.get("old_first")
|
||||||
|
if file_ep is None:
|
||||||
|
return False
|
||||||
|
end = target.episode_end if target.episode_end is not None else target.episode
|
||||||
|
return file_ep == target.episode or file_ep == end
|
||||||
|
|
||||||
|
|
||||||
|
def _range_overlaps(
|
||||||
|
season: int,
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
used_ranges: list[tuple[int, int, int]],
|
||||||
|
) -> bool:
|
||||||
|
for s, a, b in used_ranges:
|
||||||
|
if s != season:
|
||||||
|
continue
|
||||||
|
if not (end < a or start > b):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _target_range(target: EpisodeTarget) -> tuple[int, int, int]:
|
||||||
|
end = target.episode_end if target.episode_end is not None else target.episode
|
||||||
|
return target.season, target.episode, end
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_season_hint(score: float, target: EpisodeTarget, parsed: dict) -> float:
|
||||||
|
"""Prefer matches in the same season as the filename's episode code."""
|
||||||
|
file_season = parsed.get("season")
|
||||||
|
if file_season is None:
|
||||||
|
return score
|
||||||
|
if target.season == file_season:
|
||||||
|
return score + 0.08
|
||||||
|
return score * 0.5
|
||||||
|
|
||||||
|
|
||||||
def match_filenames_to_episodes(
|
def match_filenames_to_episodes(
|
||||||
filenames: list[str],
|
filenames: list[str],
|
||||||
episodes: list[TvdbEpisode],
|
episodes: list[TvdbEpisode],
|
||||||
pattern: str = DEFAULT_EPISODE_PATTERN,
|
pattern: str = DEFAULT_EPISODE_PATTERN,
|
||||||
min_score: float = 0.72,
|
min_score: float = 0.65,
|
||||||
) -> tuple[dict[str, int], list[str], list[str]]:
|
season_filter: int = 0,
|
||||||
|
official_episodes: Optional[list[TvdbEpisode]] = None,
|
||||||
|
combined_episodes: Optional[list[TvdbEpisode]] = None,
|
||||||
|
) -> tuple[dict[str, EpisodeTarget], list[str], list[str]]:
|
||||||
"""
|
"""
|
||||||
Match filenames to TheTVDB episode numbers by title.
|
Match filenames to TheTVDB episodes by title.
|
||||||
|
|
||||||
Returns:
|
season_filter: 0 = use all episodes; else only episodes from that season.
|
||||||
mapping: original filename -> correct episode number
|
official_episodes + combined_episodes: when set, also match combined-order titles
|
||||||
unmatched_files: filenames that could not be matched
|
and map to official aired numbers as Jellyfin multi-episode ranges (S01E01-E02).
|
||||||
notes: human-readable match details for UI
|
Returns mapping filename -> target, 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]
|
||||||
|
|
||||||
|
official = official_episodes or episodes
|
||||||
|
if season_filter > 0:
|
||||||
|
official = [ep for ep in official if ep.season_number == season_filter]
|
||||||
|
combined = combined_episodes
|
||||||
|
if combined and season_filter > 0:
|
||||||
|
combined = [ep for ep in combined if ep.season_number == season_filter]
|
||||||
|
|
||||||
|
file_entries: list[tuple[str, str, str, dict]] = []
|
||||||
for name in filenames:
|
for name in filenames:
|
||||||
stem = name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name
|
base = Path(name).name
|
||||||
|
stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
|
||||||
parsed = parse_episode_stem(stem, pattern)
|
parsed = parse_episode_stem(stem, pattern)
|
||||||
if not parsed or not parsed["title"]:
|
if not parsed:
|
||||||
continue
|
continue
|
||||||
norm = normalize_title(parsed["title"])
|
if season_filter > 0 and parsed["season"] != season_filter:
|
||||||
if norm:
|
continue
|
||||||
file_entries.append((name, norm, parsed["title"]))
|
norm = normalize_title(parsed["title"]) if parsed["title"] else ""
|
||||||
|
if norm or parsed.get("span", 1) > 1:
|
||||||
|
file_entries.append((name, norm, parsed["title"], parsed))
|
||||||
|
|
||||||
ep_entries = [(ep.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]] = []
|
combined_entries: list[tuple[TvdbEpisode, EpisodeTarget, list[str]]] = []
|
||||||
for fname, fnorm, raw_title in file_entries:
|
if combined:
|
||||||
for ep_num, enorm, ep_name in ep_entries:
|
for cep in combined:
|
||||||
score = _similarity(fnorm, enorm)
|
target = resolve_combined_to_official(cep, official)
|
||||||
pairs.append((score, fname, ep_num, raw_title, ep_name))
|
if target is None:
|
||||||
|
continue
|
||||||
|
combined_entries.append((cep, target, _combined_title_variants(cep.name)))
|
||||||
|
|
||||||
pairs.sort(key=lambda x: (-x[0], x[1], x[2]))
|
pairs: list[tuple[float, str, EpisodeTarget, str, str]] = []
|
||||||
|
for fname, fnorm, raw_title, parsed in file_entries:
|
||||||
|
file_season = parsed.get("season")
|
||||||
|
if season_filter == 0 and file_season is not None:
|
||||||
|
season_eps = [e for e in ep_entries if e[0] == file_season]
|
||||||
|
season_combined = [
|
||||||
|
(cep, target, variants)
|
||||||
|
for cep, target, variants in combined_entries
|
||||||
|
if target.season == file_season
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
season_eps = ep_entries
|
||||||
|
season_combined = combined_entries
|
||||||
|
|
||||||
mapping: dict[str, int] = {}
|
for season, ep_num, enorm, ep_name in season_eps:
|
||||||
|
if not fnorm:
|
||||||
|
continue
|
||||||
|
score = _apply_season_hint(
|
||||||
|
_similarity(fnorm, enorm),
|
||||||
|
EpisodeTarget(season=season, episode=ep_num),
|
||||||
|
parsed,
|
||||||
|
)
|
||||||
|
pairs.append(
|
||||||
|
(
|
||||||
|
score,
|
||||||
|
fname,
|
||||||
|
EpisodeTarget(season=season, episode=ep_num),
|
||||||
|
raw_title,
|
||||||
|
ep_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _cep, target, variants in season_combined:
|
||||||
|
if not fnorm:
|
||||||
|
continue
|
||||||
|
if not _combined_allowed_for_file(parsed, target, _cep.name):
|
||||||
|
continue
|
||||||
|
best = _combined_match_score(fnorm, _cep.name, raw_title)
|
||||||
|
best = _apply_season_hint(best, target, parsed)
|
||||||
|
pairs.append((best, fname, target, raw_title, _cep.name))
|
||||||
|
|
||||||
|
pairs.sort(key=lambda x: (-x[0], -x[2].span, x[1], x[2].season, x[2].episode))
|
||||||
|
|
||||||
|
mapping: dict[str, EpisodeTarget] = {}
|
||||||
used_files: set[str] = set()
|
used_files: set[str] = set()
|
||||||
used_eps: set[int] = set()
|
used_ranges: list[tuple[int, int, int]] = []
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
|
|
||||||
for score, fname, ep_num, raw_title, ep_name in pairs:
|
multi_file_names = {
|
||||||
|
fname for fname, _fnorm, _raw, parsed in file_entries if parsed.get("span", 1) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
def _assign_pairs(candidates: list[tuple[float, str, EpisodeTarget, str, str]]) -> None:
|
||||||
|
for score, fname, target, raw_title, ep_name in candidates:
|
||||||
if score < min_score:
|
if score < min_score:
|
||||||
break
|
break
|
||||||
if fname in used_files or ep_num in used_eps:
|
if fname in used_files:
|
||||||
continue
|
continue
|
||||||
mapping[fname] = ep_num
|
season, start, end = _target_range(target)
|
||||||
|
if _range_overlaps(season, start, end, used_ranges):
|
||||||
|
continue
|
||||||
|
mapping[fname] = target
|
||||||
used_files.add(fname)
|
used_files.add(fname)
|
||||||
used_eps.add(ep_num)
|
used_ranges.append((season, start, end))
|
||||||
pct = int(round(score * 100))
|
pct = min(100, int(round(score * 100)))
|
||||||
notes.append(f"{fname}: E{ep_num:02d} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)")
|
code = target.format_code()
|
||||||
|
notes.append(
|
||||||
|
f"{fname}: {code} ← “{ep_name}” ({pct}% match, file title “{raw_title}”)"
|
||||||
|
)
|
||||||
|
|
||||||
|
multi_pairs = [p for p in pairs if p[1] in multi_file_names]
|
||||||
|
other_pairs = [p for p in pairs if p[1] not in multi_file_names]
|
||||||
|
_assign_pairs(multi_pairs)
|
||||||
|
_assign_pairs(other_pairs)
|
||||||
|
|
||||||
unmatched_files = [
|
unmatched_files = [
|
||||||
name for name in filenames
|
name for name in filenames
|
||||||
if name not in mapping
|
if name not in mapping
|
||||||
and parse_episode_stem(
|
and parse_episode_stem(
|
||||||
name.rsplit(".", 1)[0] if "." in name and not name.startswith(".") else name,
|
(
|
||||||
|
Path(name).name.rsplit(".", 1)[0]
|
||||||
|
if "." in Path(name).name and not Path(name).name.startswith(".")
|
||||||
|
else Path(name).name
|
||||||
|
),
|
||||||
pattern,
|
pattern,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+9
-5
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from .episode_match import rewrite_episode_number
|
from .episode_match import rewrite_episode_stem, EpisodeTarget, _coerce_target, parse_episode_stem
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -279,12 +279,16 @@ class TvdbEpisodeRenumberRule(Rule):
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
if not self.episode_mapping or original_name is None:
|
if not self.episode_mapping or original_name is None:
|
||||||
return stem, ext
|
return stem, ext
|
||||||
new_ep = self.episode_mapping.get(original_name)
|
target_raw = self.episode_mapping.get(original_name)
|
||||||
if new_ep is None:
|
if target_raw is None:
|
||||||
return stem, ext
|
return stem, ext
|
||||||
new_stem = rewrite_episode_number(
|
parsed = parse_episode_stem(stem, self.pattern)
|
||||||
|
if not parsed:
|
||||||
|
return stem, ext
|
||||||
|
target = _coerce_target(target_raw, parsed)
|
||||||
|
new_stem = rewrite_episode_stem(
|
||||||
stem,
|
stem,
|
||||||
int(new_ep),
|
target,
|
||||||
padding=self.padding,
|
padding=self.padding,
|
||||||
pattern=self.pattern,
|
pattern=self.pattern,
|
||||||
)
|
)
|
||||||
|
|||||||
+91
-20
@@ -20,10 +20,16 @@ SEASON_TYPE_CHOICES: list[tuple[str, str]] = [
|
|||||||
("Official / aired order", "official"),
|
("Official / aired order", "official"),
|
||||||
("DVD order", "dvd"),
|
("DVD order", "dvd"),
|
||||||
("Absolute order", "absolute"),
|
("Absolute order", "absolute"),
|
||||||
("Alternate order", "alternate"),
|
("Combined order (multi-episode files)", "alternate"),
|
||||||
("Regional order", "regional"),
|
("Regional order", "regional"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Language path segment for /series/{id}/episodes/{season-type}/{language}
|
||||||
|
LANGUAGE_CHOICES: list[tuple[str, str]] = [
|
||||||
|
("Default (show language)", ""),
|
||||||
|
("English", "eng"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TvdbSeries:
|
class TvdbSeries:
|
||||||
@@ -154,40 +160,105 @@ class TvdbClient:
|
|||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_season_episodes(
|
def _episodes_path(self, series_id: int, season_type: str, language: str = "") -> str:
|
||||||
|
base = f"/series/{series_id}/episodes/{season_type}"
|
||||||
|
if language:
|
||||||
|
return f"{base}/{language}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _parse_episode_batch(
|
||||||
self,
|
self,
|
||||||
series_id: int,
|
batch: list[dict[str, Any]],
|
||||||
season: int,
|
*,
|
||||||
season_type: str = "default",
|
season: Optional[int] = None,
|
||||||
|
skip_specials: bool = False,
|
||||||
) -> list[TvdbEpisode]:
|
) -> list[TvdbEpisode]:
|
||||||
episodes: 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:
|
for ep in batch:
|
||||||
if ep.get("seasonNumber") != season:
|
ep_season = ep.get("seasonNumber")
|
||||||
continue
|
|
||||||
number = ep.get("number")
|
number = ep.get("number")
|
||||||
name = ep.get("name")
|
name = ep.get("name")
|
||||||
if number is None or not name:
|
if number is None or ep_season is None or not name:
|
||||||
|
continue
|
||||||
|
ep_season = int(ep_season)
|
||||||
|
if skip_specials and ep_season == 0:
|
||||||
|
continue
|
||||||
|
if season is not None and ep_season != season:
|
||||||
continue
|
continue
|
||||||
episodes.append(
|
episodes.append(
|
||||||
TvdbEpisode(
|
TvdbEpisode(
|
||||||
number=int(number),
|
number=int(number),
|
||||||
season_number=int(season),
|
season_number=ep_season,
|
||||||
name=str(name),
|
name=str(name),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def _fetch_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season_type: str,
|
||||||
|
*,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
language: str = "",
|
||||||
|
skip_specials: bool = False,
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
"""Fetch episodes; optional translated titles via language code (e.g. eng)."""
|
||||||
|
episodes: list[TvdbEpisode] = []
|
||||||
|
page = 0
|
||||||
|
max_pages = 50
|
||||||
|
use_api_season = season is not None and not language
|
||||||
|
while page < max_pages:
|
||||||
|
params: dict[str, Any] = {"page": page}
|
||||||
|
if use_api_season:
|
||||||
|
params["season"] = season
|
||||||
|
payload = self._request(
|
||||||
|
"GET",
|
||||||
|
self._episodes_path(series_id, season_type, language),
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
batch = (payload.get("data") or {}).get("episodes") or []
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
filter_season = season if language else None
|
||||||
|
episodes.extend(
|
||||||
|
self._parse_episode_batch(
|
||||||
|
batch,
|
||||||
|
season=filter_season,
|
||||||
|
skip_specials=skip_specials,
|
||||||
|
)
|
||||||
|
)
|
||||||
links = payload.get("links") or {}
|
links = payload.get("links") or {}
|
||||||
if not links.get("next"):
|
if not links.get("next"):
|
||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
episodes.sort(key=lambda e: e.number)
|
episodes.sort(key=lambda e: (e.season_number, e.number))
|
||||||
return episodes
|
return episodes
|
||||||
|
|
||||||
|
def get_season_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season: int,
|
||||||
|
season_type: str = "default",
|
||||||
|
language: str = "",
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
return self._fetch_episodes(
|
||||||
|
series_id,
|
||||||
|
season_type,
|
||||||
|
season=season,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_episodes(
|
||||||
|
self,
|
||||||
|
series_id: int,
|
||||||
|
season_type: str = "default",
|
||||||
|
language: str = "",
|
||||||
|
) -> list[TvdbEpisode]:
|
||||||
|
"""Fetch every episode for a series (all seasons), paginated."""
|
||||||
|
return self._fetch_episodes(
|
||||||
|
series_id,
|
||||||
|
season_type,
|
||||||
|
language=language,
|
||||||
|
skip_specials=True,
|
||||||
|
)
|
||||||
|
|||||||
+94
-11
@@ -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, QTimer
|
||||||
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,
|
||||||
@@ -50,7 +53,10 @@ 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._preview_refresh_scheduled = False
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
self._restore_layout()
|
||||||
self._refresh_preview()
|
self._refresh_preview()
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
@@ -70,6 +76,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)
|
||||||
|
|
||||||
@@ -108,7 +130,7 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(w, "set_file_names"):
|
if hasattr(w, "set_file_names"):
|
||||||
w.set_file_names(self._file_names)
|
w.set_file_names(self._file_names)
|
||||||
if hasattr(w, "matchCompleted"):
|
if hasattr(w, "matchCompleted"):
|
||||||
w.matchCompleted.connect(self._refresh_preview)
|
w.matchCompleted.connect(self._schedule_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)
|
||||||
@@ -153,13 +175,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):
|
||||||
@@ -167,9 +229,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()
|
||||||
|
|
||||||
@@ -189,9 +260,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,6 +275,19 @@ class MainWindow(QMainWindow):
|
|||||||
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
|
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
|
||||||
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
|
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
|
||||||
|
|
||||||
|
def _schedule_refresh_preview(self):
|
||||||
|
if self._preview_refresh_scheduled:
|
||||||
|
return
|
||||||
|
self._preview_refresh_scheduled = True
|
||||||
|
QTimer.singleShot(0, self._run_scheduled_refresh)
|
||||||
|
|
||||||
|
def _run_scheduled_refresh(self):
|
||||||
|
self._preview_refresh_scheduled = False
|
||||||
|
try:
|
||||||
|
self._refresh_preview()
|
||||||
|
except Exception as exc:
|
||||||
|
self.preview_status.setText(f"Preview error: {exc}")
|
||||||
|
|
||||||
def _refresh_preview(self):
|
def _refresh_preview(self):
|
||||||
rules = self._get_rules()
|
rules = self._get_rules()
|
||||||
if not self._file_names:
|
if not self._file_names:
|
||||||
@@ -263,7 +345,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):
|
||||||
@@ -317,7 +400,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:
|
||||||
@@ -349,4 +432,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()
|
||||||
|
|||||||
+174
-28
@@ -31,8 +31,8 @@ from engine.rules import (
|
|||||||
PrefixSuffixRule,
|
PrefixSuffixRule,
|
||||||
CsvMappingRule,
|
CsvMappingRule,
|
||||||
)
|
)
|
||||||
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES
|
from engine.tvdb_client import TvdbClient, TvdbError, TVDB_API_KEY, SEASON_TYPE_CHOICES, LANGUAGE_CHOICES
|
||||||
from engine.episode_match import match_filenames_to_episodes
|
from engine.episode_match import match_filenames_to_episodes, target_to_tuple
|
||||||
|
|
||||||
|
|
||||||
class ReplaceRuleWidget(QWidget):
|
class ReplaceRuleWidget(QWidget):
|
||||||
@@ -310,18 +310,19 @@ class EpisodeRenumberRuleWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbSearchWorker(QThread):
|
class _TvdbSearchWorker(QThread):
|
||||||
finished = pyqtSignal(list)
|
search_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, query: str):
|
def __init__(self, query: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.query = query
|
self.query = query
|
||||||
|
self.results: list = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
results = client.search_series(self.query)
|
self.results = client.search_series(self.query)
|
||||||
self.finished.emit(results)
|
self.search_done.emit()
|
||||||
except TvdbError as e:
|
except TvdbError as e:
|
||||||
self.failed.emit(str(e))
|
self.failed.emit(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -329,7 +330,7 @@ class _TvdbSearchWorker(QThread):
|
|||||||
|
|
||||||
|
|
||||||
class _TvdbMatchWorker(QThread):
|
class _TvdbMatchWorker(QThread):
|
||||||
finished = pyqtSignal(dict, list, list)
|
match_done = pyqtSignal()
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -338,29 +339,116 @@ class _TvdbMatchWorker(QThread):
|
|||||||
season: int,
|
season: int,
|
||||||
season_type: str,
|
season_type: str,
|
||||||
filenames: list[str],
|
filenames: list[str],
|
||||||
|
all_seasons: bool,
|
||||||
|
multi_episode: bool,
|
||||||
|
language: str,
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.series_id = series_id
|
self.series_id = series_id
|
||||||
self.season = season
|
self.season = season
|
||||||
self.season_type = season_type
|
self.season_type = season_type
|
||||||
self.filenames = filenames
|
self.filenames = filenames
|
||||||
|
self.all_seasons = all_seasons
|
||||||
|
self.multi_episode = multi_episode
|
||||||
|
self.language = language
|
||||||
|
self.mapping: dict[str, tuple[int, ...]] = {}
|
||||||
|
self.unmatched: list[str] = []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
client = TvdbClient(TVDB_API_KEY)
|
client = TvdbClient(TVDB_API_KEY)
|
||||||
|
lang = self.language
|
||||||
|
season_filter = 0 if self.all_seasons else self.season
|
||||||
|
need_official = self.multi_episode or self.season_type == "alternate"
|
||||||
|
|
||||||
|
if self.all_seasons:
|
||||||
|
episodes = client.get_all_episodes(
|
||||||
|
self.series_id,
|
||||||
|
season_type=self.season_type,
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
episodes = client.get_season_episodes(
|
episodes = client.get_season_episodes(
|
||||||
self.series_id,
|
self.series_id,
|
||||||
self.season,
|
self.season,
|
||||||
season_type=self.season_type,
|
season_type=self.season_type,
|
||||||
|
language=lang,
|
||||||
)
|
)
|
||||||
if not episodes:
|
|
||||||
raise TvdbError(f"No episodes found for season {self.season}")
|
if self.season_type == "alternate":
|
||||||
mapping, unmatched, notes = match_filenames_to_episodes(self.filenames, episodes)
|
if self.all_seasons:
|
||||||
self.finished.emit(mapping, unmatched, notes)
|
official = client.get_all_episodes(
|
||||||
|
self.series_id, season_type="official", language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = client.get_season_episodes(
|
||||||
|
self.series_id,
|
||||||
|
self.season,
|
||||||
|
season_type="official",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
episodes = official
|
||||||
|
elif need_official:
|
||||||
|
if self.season_type == "official":
|
||||||
|
official = episodes
|
||||||
|
elif self.all_seasons:
|
||||||
|
official = client.get_all_episodes(
|
||||||
|
self.series_id, season_type="official", language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = client.get_season_episodes(
|
||||||
|
self.series_id,
|
||||||
|
self.season,
|
||||||
|
season_type="official",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
official = episodes
|
||||||
|
|
||||||
|
combined = None
|
||||||
|
if need_official:
|
||||||
|
if self.all_seasons:
|
||||||
|
combined = client.get_all_episodes(
|
||||||
|
self.series_id,
|
||||||
|
season_type="alternate",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
combined = client.get_season_episodes(
|
||||||
|
self.series_id,
|
||||||
|
self.season,
|
||||||
|
season_type="alternate",
|
||||||
|
language=lang,
|
||||||
|
)
|
||||||
|
if not combined and lang:
|
||||||
|
if self.all_seasons:
|
||||||
|
combined = client.get_all_episodes(
|
||||||
|
self.series_id, season_type="alternate",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
combined = client.get_season_episodes(
|
||||||
|
self.series_id, self.season, season_type="alternate",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not episodes and not combined:
|
||||||
|
label = "all seasons" if self.all_seasons else f"season {self.season}"
|
||||||
|
raise TvdbError(f"No episodes found for {label}")
|
||||||
|
mapping, unmatched, notes = match_filenames_to_episodes(
|
||||||
|
self.filenames,
|
||||||
|
episodes,
|
||||||
|
season_filter=season_filter,
|
||||||
|
official_episodes=official,
|
||||||
|
combined_episodes=combined,
|
||||||
|
)
|
||||||
|
self.mapping = {path: target_to_tuple(t) for path, t in mapping.items()}
|
||||||
|
self.unmatched = list(unmatched)
|
||||||
|
del notes
|
||||||
|
self.match_done.emit()
|
||||||
except TvdbError as e:
|
except TvdbError as e:
|
||||||
self.failed.emit(str(e))
|
self.failed.emit(str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.failed.emit(str(e))
|
import traceback
|
||||||
|
self.failed.emit(f"{e}\n\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
class TvdbEpisodeRenumberRuleWidget(QWidget):
|
||||||
@@ -404,10 +492,18 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.order_type.currentIndexChanged.connect(self._emit)
|
self.order_type.currentIndexChanged.connect(self._emit)
|
||||||
layout.addRow("Episode order:", self.order_type)
|
layout.addRow("Episode order:", self.order_type)
|
||||||
|
|
||||||
|
self.title_language = QComboBox()
|
||||||
|
for label, value in LANGUAGE_CHOICES:
|
||||||
|
self.title_language.addItem(label, value)
|
||||||
|
self.title_language.setCurrentIndex(1) # English — most filenames use it
|
||||||
|
self.title_language.currentIndexChanged.connect(self._emit)
|
||||||
|
layout.addRow("Episode titles:", self.title_language)
|
||||||
|
|
||||||
self.season = QSpinBox()
|
self.season = QSpinBox()
|
||||||
self.season.setMinimum(0)
|
self.season.setMinimum(0)
|
||||||
self.season.setMaximum(99)
|
self.season.setMaximum(99)
|
||||||
self.season.setValue(1)
|
self.season.setValue(0)
|
||||||
|
self.season.setSpecialValueText("All seasons")
|
||||||
self.season.valueChanged.connect(self._emit)
|
self.season.valueChanged.connect(self._emit)
|
||||||
layout.addRow("Season:", self.season)
|
layout.addRow("Season:", self.season)
|
||||||
|
|
||||||
@@ -418,17 +514,28 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.padding.valueChanged.connect(self._emit)
|
self.padding.valueChanged.connect(self._emit)
|
||||||
layout.addRow("Zero-pad width:", self.padding)
|
layout.addRow("Zero-pad width:", self.padding)
|
||||||
|
|
||||||
|
self.multi_episode_cb = QCheckBox("Multi-episode files (Jellyfin S01E01-E02)")
|
||||||
|
self.multi_episode_cb.setChecked(True)
|
||||||
|
self.multi_episode_cb.setToolTip(
|
||||||
|
"Match combined-order titles from TheTVDB and rename using aired episode ranges, "
|
||||||
|
"e.g. two episodes in one file becomes S01E01-E02."
|
||||||
|
)
|
||||||
|
self.multi_episode_cb.toggled.connect(self._emit)
|
||||||
|
layout.addRow(self.multi_episode_cb)
|
||||||
|
|
||||||
self.match_btn = QPushButton("Match titles from file list")
|
self.match_btn = QPushButton("Match titles from file list")
|
||||||
self.match_btn.clicked.connect(self._match_titles)
|
self.match_btn.clicked.connect(self._match_titles)
|
||||||
layout.addRow(self.match_btn)
|
layout.addRow(self.match_btn)
|
||||||
|
|
||||||
self.status = QLabel("Search for a show, choose episode order and season, then match titles.")
|
self.status = QLabel("Search for a show, choose episode order, then match titles (0 = all seasons).")
|
||||||
self.status.setWordWrap(True)
|
self.status.setWordWrap(True)
|
||||||
layout.addRow(self.status)
|
layout.addRow(self.status)
|
||||||
|
|
||||||
info = QLabel(
|
info = QLabel(
|
||||||
"Reads episode titles from filenames like S01E05 - Episode Title. "
|
"Reads titles from S01E05 - Episode Title, S01E05-E06 (multi-episode), "
|
||||||
"Fetches the chosen TheTVDB episode order for that season and rewrites SxxExx numbers to match."
|
"or Show 04x01 Episode Title filenames. "
|
||||||
|
"For non-English shows, set Episode titles to English if your filenames use English. "
|
||||||
|
"Use All seasons to match across every season and fix wrong season numbers too."
|
||||||
)
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
layout.addRow(info)
|
layout.addRow(info)
|
||||||
@@ -443,6 +550,13 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
value = self.order_type.currentData()
|
value = self.order_type.currentData()
|
||||||
return value if value else "default"
|
return value if value else "default"
|
||||||
|
|
||||||
|
def _language_value(self) -> str:
|
||||||
|
value = self.title_language.currentData()
|
||||||
|
return value if value else ""
|
||||||
|
|
||||||
|
def _language_label(self) -> str:
|
||||||
|
return self.title_language.currentText()
|
||||||
|
|
||||||
def _emit(self):
|
def _emit(self):
|
||||||
self.ruleChanged.emit()
|
self.ruleChanged.emit()
|
||||||
|
|
||||||
@@ -455,11 +569,12 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
self.series_combo.setEnabled(False)
|
self.series_combo.setEnabled(False)
|
||||||
self.status.setText("Searching TheTVDB…")
|
self.status.setText("Searching TheTVDB…")
|
||||||
self._search_worker = _TvdbSearchWorker(query)
|
self._search_worker = _TvdbSearchWorker(query)
|
||||||
self._search_worker.finished.connect(self._on_search_finished)
|
self._search_worker.search_done.connect(self._on_search_finished)
|
||||||
self._search_worker.failed.connect(self._on_search_failed)
|
self._search_worker.failed.connect(self._on_search_failed)
|
||||||
self._search_worker.start()
|
self._search_worker.start()
|
||||||
|
|
||||||
def _on_search_finished(self, results):
|
def _on_search_finished(self):
|
||||||
|
results = self._search_worker.results if self._search_worker else []
|
||||||
self._search_results = results
|
self._search_results = results
|
||||||
self.series_combo.clear()
|
self.series_combo.clear()
|
||||||
if not results:
|
if not results:
|
||||||
@@ -491,40 +606,71 @@ class TvdbEpisodeRenumberRuleWidget(QWidget):
|
|||||||
series_id = self.series_combo.currentData()
|
series_id = self.series_combo.currentData()
|
||||||
if series_id is None:
|
if series_id is None:
|
||||||
return
|
return
|
||||||
|
if self._match_worker is not None and self._match_worker.isRunning():
|
||||||
|
self.status.setText("Matching already in progress…")
|
||||||
|
return
|
||||||
self.match_btn.setEnabled(False)
|
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()}, {self._language_label()}, "
|
||||||
|
f"{'all seasons' if all_seasons else f'season {self.season.value()}'}…) and matching titles…"
|
||||||
|
)
|
||||||
self._match_worker = _TvdbMatchWorker(
|
self._match_worker = _TvdbMatchWorker(
|
||||||
int(series_id),
|
int(series_id),
|
||||||
self.season.value(),
|
self.season.value(),
|
||||||
self._order_value(),
|
self._order_value(),
|
||||||
self._file_names,
|
self._file_names,
|
||||||
|
all_seasons,
|
||||||
|
self.multi_episode_cb.isChecked(),
|
||||||
|
self._language_value(),
|
||||||
)
|
)
|
||||||
self._match_worker.finished.connect(self._on_match_finished)
|
self._match_worker.match_done.connect(self._on_match_finished)
|
||||||
self._match_worker.failed.connect(self._on_match_failed)
|
self._match_worker.failed.connect(self._on_match_failed)
|
||||||
|
self._match_worker.finished.connect(self._on_match_worker_finished)
|
||||||
self._match_worker.start()
|
self._match_worker.start()
|
||||||
|
|
||||||
def _on_match_finished(self, mapping: dict, unmatched: list, notes: list):
|
def _on_match_worker_finished(self):
|
||||||
self._episode_mapping = mapping
|
worker = self._match_worker
|
||||||
|
if worker is not None:
|
||||||
|
worker.deleteLater()
|
||||||
|
self._match_worker = None
|
||||||
|
|
||||||
|
def _on_match_finished(self):
|
||||||
|
try:
|
||||||
|
worker = self._match_worker
|
||||||
|
if worker is None:
|
||||||
|
return
|
||||||
|
self._episode_mapping = dict(worker.mapping)
|
||||||
|
unmatched = list(worker.unmatched)
|
||||||
self.match_btn.setEnabled(True)
|
self.match_btn.setEnabled(True)
|
||||||
matched = len(mapping)
|
matched = len(self._episode_mapping)
|
||||||
total = len(self._file_names)
|
total = len(self._file_names)
|
||||||
msg = (
|
scope = "all seasons" if self.season.value() == 0 else f"season {self.season.value()}"
|
||||||
f"Matched {matched} of {total} file(s) for season {self.season.value()} "
|
msg = f"Matched {matched} of {total} file(s) ({scope}, {self._order_label()}, {self._language_label()})."
|
||||||
f"({self._order_label()})."
|
|
||||||
)
|
|
||||||
if unmatched:
|
if unmatched:
|
||||||
msg += f" {len(unmatched)} file(s) unmatched."
|
msg += f" {len(unmatched)} file(s) unmatched."
|
||||||
self.status.setText(msg)
|
self.status.setText(msg)
|
||||||
self.matchCompleted.emit()
|
self.matchCompleted.emit()
|
||||||
self._emit()
|
except Exception as e:
|
||||||
|
self.match_btn.setEnabled(True)
|
||||||
|
self.status.setText(f"Match error: {e}")
|
||||||
|
QMessageBox.warning(self, "Match error", str(e))
|
||||||
|
|
||||||
def _on_match_failed(self, message: str):
|
def _on_match_failed(self, message: str):
|
||||||
self.match_btn.setEnabled(True)
|
self.match_btn.setEnabled(True)
|
||||||
self.status.setText(f"Match failed: {message}")
|
self.status.setText(f"Match failed: {message}")
|
||||||
|
|
||||||
def getRule(self) -> TvdbEpisodeRenumberRule:
|
def getRule(self) -> TvdbEpisodeRenumberRule:
|
||||||
|
mapping: dict = {}
|
||||||
|
for k, v in self._episode_mapping.items():
|
||||||
|
if isinstance(v, tuple):
|
||||||
|
mapping[k] = v
|
||||||
|
elif hasattr(v, "season"):
|
||||||
|
mapping[k] = target_to_tuple(v)
|
||||||
|
else:
|
||||||
|
mapping[k] = v
|
||||||
r = TvdbEpisodeRenumberRule(
|
r = TvdbEpisodeRenumberRule(
|
||||||
episode_mapping=dict(self._episode_mapping),
|
episode_mapping=mapping,
|
||||||
padding=self.padding.value(),
|
padding=self.padding.value(),
|
||||||
)
|
)
|
||||||
r.enabled = self.enabled_cb.isChecked()
|
r.enabled = self.enabled_cb.isChecked()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ HSRename - Native Linux GUI for mass renaming files.
|
|||||||
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
Inspired by Bulk Rename Utility (Windows); supports preview and flexible rules.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ensure project root is on path when run as script or module
|
# Ensure project root is on path when run as script or module
|
||||||
@@ -16,12 +17,35 @@ from PyQt6.QtCore import Qt
|
|||||||
from gui.main_window import MainWindow
|
from gui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def _install_exception_logger() -> None:
|
||||||
|
log_dir = Path.home() / ".config" / "HSRename"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / "crash.log"
|
||||||
|
|
||||||
|
def _hook(exc_type, exc, tb):
|
||||||
|
try:
|
||||||
|
log_file.write_text(
|
||||||
|
"".join(traceback.format_exception(exc_type, exc, tb)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
sys.__excepthook__(exc_type, exc, tb)
|
||||||
|
|
||||||
|
sys.excepthook = _hook
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
_install_exception_logger()
|
||||||
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
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,4 @@
|
|||||||
|
HSRename 1.0.10
|
||||||
|
- Multi-episode files: Jellyfin S01E01-E02 naming from TheTVDB combined order
|
||||||
|
- Match combined titles (Ep A/Ep B) and map to official aired episode ranges
|
||||||
|
- Combined order option in episode order dropdown; multi-episode toggle on TheTVDB rule
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
HSRename 1.0.11
|
||||||
|
- Fix multi-episode detection when filename uses end episode (S01E06 for E05-E06)
|
||||||
|
- Detect dual titles separated by " - " (Otto 3000 - Night Prowler) as combined pairs
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
HSRename 1.0.12
|
||||||
|
- Episode titles language selector (English / show default) for TheTVDB matching
|
||||||
|
- Fixes 0 matches on anime and other shows when filenames are English but TheTVDB default is Japanese
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.13
|
||||||
|
- Fix crash during TheTVDB match (safe thread signal payloads, worker re-entry guard)
|
||||||
|
- Faster matching by scoping episodes to each file's season
|
||||||
|
- Fewer redundant TheTVDB API calls during match
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HSRename 1.0.14
|
||||||
|
- Fix Qt thread crashes: workers no longer pass dict/list through signals
|
||||||
|
- Defer preview refresh after match to avoid re-entrant table updates
|
||||||
|
- Log Python exceptions to ~/.config/HSRename/crash.log
|
||||||
@@ -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
|
||||||
+36
-16
@@ -2,9 +2,10 @@
|
|||||||
# Push tag to Gitea and publish HSRename.AppImage as a release asset.
|
# Push tag to Gitea and publish HSRename.AppImage as a release asset.
|
||||||
# Usage:
|
# Usage:
|
||||||
# GITEA_TOKEN=your_token ./release_gitea.sh
|
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
@@ -22,18 +23,38 @@ APP_IMAGE="HSRename.AppImage"
|
|||||||
VERSION="$(tr -d '[:space:]' < VERSION)"
|
VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
if [[ "${1:-}" == "--build" ]]; then
|
verify_appimage() {
|
||||||
export PATH="/tmp:${PATH}"
|
if [[ ! -f "$APP_IMAGE" ]]; then
|
||||||
if ! command -v appimagetool &>/dev/null && [[ -x /tmp/appimagetool ]]; then
|
echo "Missing $APP_IMAGE after build." >&2
|
||||||
export PATH="/tmp:${PATH}"
|
exit 1
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$APP_IMAGE" ]]; then
|
echo "=== Building fresh AppImage for ${TAG} ==="
|
||||||
echo "Missing $APP_IMAGE. Run ./build_appimage.sh or ./release_gitea.sh --build"
|
./build_appimage.sh
|
||||||
exit 1
|
verify_appimage
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Tag $TAG not found. Create it with: git tag $TAG"
|
echo "Tag $TAG not found. Create it with: git tag $TAG"
|
||||||
@@ -46,10 +67,8 @@ git push origin "$TAG"
|
|||||||
|
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
echo ""
|
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 " GITEA_TOKEN=... ./release_gitea.sh"
|
||||||
echo ""
|
|
||||||
echo "Or create the release manually on Gitea and attach $APP_IMAGE."
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -57,9 +76,10 @@ API="${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases"
|
|||||||
NOTES="$(cat <<EOF
|
NOTES="$(cat <<EOF
|
||||||
## HSRename ${TAG}
|
## HSRename ${TAG}
|
||||||
|
|
||||||
See repository history for changes in this release.
|
- 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}\`
|
\`${GITEA_HOST}/${GITEA_OWNER}/${GITEA_REPO}/releases/download/${TAG}/${APP_IMAGE}\`
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
|
|||||||
Reference in New Issue
Block a user